Frontend rewrite: React + TypeScript with AntD v6 (#4498)
* chore(frontend): add react+typescript toolchain alongside vue
Step 0 of the planned vue->react migration. React 19, antd 5, i18next
+ react-i18next, typescript 5, and @vitejs/plugin-react 6 are added as
dev/runtime deps alongside the existing vue stack. Both frameworks
coexist in the build until the last entry flips.
* vite.config.js: react() plugin runs next to vue(); new manualChunks
for vendor-react / vendor-antd-react / vendor-icons-react /
vendor-i18next. Existing vue chunks unchanged.
* eslint.config.js: typescript-eslint + eslint-plugin-react-hooks
rules scoped to *.{ts,tsx}; vue config untouched for *.{js,vue}.
* tsconfig.json: strict, jsx: react-jsx, moduleResolution: bundler,
allowJs: true (lets .tsx files import the remaining .js modules
during incremental migration), @/* path alias.
* env.d.ts: Vite client types + window.X_UI_BASE_PATH typing +
SubPageData shape consumed by the subscription page.
Vite stays pinned at 8.0.13 per the existing project policy. No
existing .vue/.js source files touched in this step.
eslint-plugin-react (not -hooks) is not included because its latest
release does not yet support ESLint 10. react-hooks/purity covers
the safety-critical case; revisit when the plugin updates.
* refactor(frontend): port subpage to react+ts
Step 1 of the planned vue->react migration. The standalone
subscription page (sub/sub.go renders the HTML host; React mounts
into #app) is the first entry off vue.
Introduces two shared pieces both entries (and future ones) will
use:
* src/hooks/useTheme.tsx — React Context + useTheme hook + the
same buildAntdThemeConfig (dark/ultra-dark token overrides) and
pauseAnimationsUntilLeave helper the vue version exposes. Same
localStorage keys (dark-mode, isUltraDarkThemeEnabled) and DOM
side effects (body.className, html[data-theme]) so the two stay
in sync across the coexistence period.
* src/i18n/react.ts — i18next + react-i18next loader that reads
the same web/translation/*.json files via import.meta.glob. The
vue-i18n setup in src/i18n/index.js is untouched and still serves
the remaining vue entries.
SubPage.tsx mirrors the vue version's behavior: reads
window.__SUB_PAGE_DATA__ injected by the Go sub server, renders QR
codes / descriptions / Android+iOS deep-link dropdowns, supports
theme cycle and language switch. Uses AntD v5 idioms: Descriptions
items prop, Dropdown menu prop, Layout.Content.
* refactor(frontend): port login to react+ts
Step 2 of the planned vue->react migration. The login entry is the
first to exercise AntD React's Form API (Form + Form.Item with
name/rules + onFinish) and the existing axios/CSRF interceptors
under React.
* LoginPage.tsx: same form fields, conditional 2FA input,
rotating headline ("Hello" / "Welcome to..."), drifting blob
background, theme cycle + language popover. Headline transition
switches from vue's <Transition mode=out-in> to a CSS keyframe
animation keyed off the visible word.
* entries/login.tsx: setupAxios() + applyDocumentTitle() unchanged
from the vue entry — both are framework-agnostic in src/utils
and src/api/axios-init.js.
useTheme hook, ThemeProvider, and i18n/react.ts loader introduced
in step 1 are now shared across two entries; Vite extracts them as
a small chunk in the build output.
* refactor(frontend): port api-docs to react+ts
Step 3 of the planned vue->react migration. The five api-docs files
(ApiDocsPage, CodeBlock, EndpointRow, EndpointSection, plus the
data-only endpoints.js) all move to react+ts.
Also introduces components/AppSidebar.tsx — api-docs is the first
authenticated page to need it. AppSidebar.vue stays in place for the
six remaining vue entries (settings, inbounds, clients, xray, nodes,
index); each gets switched to AppSidebar.tsx as its entry migrates.
After the last entry flips, AppSidebar.vue is deleted.
Notable transformations:
* The scroll observer that highlights the active TOC link is a
useEffect keyed on sections — re-registers whenever the visible
set changes (search filter narrows it). Same behaviour as the vue
watchEffect.
* v-html="safeInlineHtml(...)" becomes
dangerouslySetInnerHTML={{ __html: safeInlineHtml(...) }}. The
helper still escapes everything except <code> tags.
* JSON syntax highlighter in CodeBlock is unchanged — pure regex on
the escaped string, then rendered via dangerouslySetInnerHTML.
* endpoints.js stays as JS (allowJs in tsconfig); only the consumer
signatures (Endpoint, Section) are typed at the React boundary.
* AppSidebar reuses pauseAnimationsUntilLeave + useTheme from
step 1. Drawer + Sider keyed off the same localStorage flag
(isSidebarCollapsed) and DOM theme attributes the vue version
uses, so the two stay in sync during coexistence.
* refactor(frontend): port nodes to react+ts
Step 4 of the planned vue->react migration. The nodes entry brings in
the largest shared-infrastructure batch so far — every authenticated
react page from here on can lean on these.
New shared pieces (live alongside their .vue counterparts during
coexistence):
* hooks/useMediaQuery.ts — useState + resize listener
* hooks/useWebSocket.ts — wraps WebSocketClient, subscribes on mount
and unsubscribes on unmount. The underlying client is a single
module-level instance so multiple components on the same page
share one socket.
* hooks/useNodes.ts — node list state + CRUD + probe/test, including
the totals memo (online/offline/avgLatency) used by the summary card.
applyNodesEvent is the entry point for the heartbeat-pushed list.
* components/CustomStatistic.tsx — thin Statistic wrapper, prefix +
suffix slots become props.
* components/Sparkline.tsx — the SVG line chart with measured-width
axis scaling, gradient fill, tooltip overlay, and per-instance
gradient id from React.useId. ResizeObserver lifecycle is in
useEffect; the math is unchanged.
Pages:
* NodesPage — wires hooks + WebSocket together, renders summary card
+ NodeList, hosts the form modal. Uses Modal.useModal() for the
delete confirm so the dialog inherits ConfigProvider theming.
* NodeList — desktop renders a Table with expandable history rows;
mobile flips to a vertical card list whose actions live in a
bottom-right Dropdown. The IP-blur eye toggle persists across both.
* NodeFormModal — controlled form (useState object, single setForm
per change). The reset-on-open effect computes the next state
once and applies it with eslint-disable to satisfy the new
react-hooks/set-state-in-effect rule on a legitimate pattern.
* NodeHistoryPanel — polls /panel/api/nodes/history/{id}/{metric}/
{bucket} every 15s, renders cpu+mem sparklines side-by-side.
* refactor(frontend): port settings to react+ts
Step 5 of the planned vue->react migration. Settings is the first
entry whose state model didn't translate to the Vue-style "parent
passes a reactive object, children mutate it in place" pattern, so
the React port flips it to lifted state + a typed updateSetting
patch function.
* models/setting.ts — typed AllSetting class with the same field
defaults and equals() behavior the vue version had. The .js
twin is deleted; nothing else imported it.
* hooks/useAllSetting.ts — owns allSetting + oldAllSetting state,
exposes updateSetting(patch), saveDisabled is derived via useMemo
off equals() (no more 1Hz dirty-check timer).
* components/SettingListItem.tsx — children-based wrapper instead
of named slots. The vue twin stays alive because xray (BasicsTab,
DnsTab) still imports it; deleted when xray migrates.
The five tab components and the TwoFactorModal each accept
{ allSetting, updateSetting } and render with AntD v5's Collapse
items[] API. Every v-model:value="x" became
value={...} onChange={(e) => updateSetting({ key: e.target.value })}
or onChange={(v) => updateSetting({ key: v })} for non-input
controls.
SubscriptionFormatsTab is the trickiest — fragment / noises[] /
mux / direct routing rules are stored as JSON-encoded strings on
the wire. Parsing them once via useMemo per field, mutating the
parsed object on edit, and stringifying back into the patch keeps
the round-trip identical to the vue version.
SettingsPage hosts the tab navigation (with hash sync), the
save / restart action bar, the security-warnings alert banner,
and the restart flow that rebuilds the panel URL after the new
host/port/cert settings take effect.
* refactor(frontend): port clients to react+ts
Step 6 of the planned vue->react migration. Clients is the biggest
data-CRUD page in the panel (1.1k-line ClientsPage, 4 modals, full
table + mobile card list, WebSocket-driven realtime traffic + online
updates).
New shared infra (lives alongside vue twins until inbounds migrates):
* hooks/useClients.ts — clients + inbounds list, CRUD + bulk delete +
attach/detach + traffic reset, with WebSocket event handlers
(traffic, client_stats, invalidate) and a small debounced refresh
on the invalidate event. State managed via setState; the live
client_stats event merges traffic snapshots row-by-row through a
ref to avoid stale closure issues.
* hooks/useDatepicker.ts — singleton "gregorian"/"jalalian" cache
with subscribe/notify so multiple components can read the panel's
Calendar Type without re-fetching. Mirrors useDatepicker.js.
* components/DateTimePicker.tsx — AntD DatePicker wrapper.
vue3-persian-datetime-picker has no React port; the Jalali UI
calendar is deferred (read-only Jalali display via IntlUtil
formatDate still works). The vue twin stays for inbounds.
* pages/inbounds/QrPanel.tsx — copy/download/copy-as-png QR helper
shared between clients (qr modal) and inbounds (still on vue).
Vue twin stays alive at QrPanel.vue.
* models/inbound.ts — slim port: only the TLS_FLOW_CONTROL constant
the clients form needs. The full inbound model stays as
inbound.js for now; inbounds will pull it in as inbound.ts.
The clients page itself uses Modal.useModal() for all confirm
dialogs (delete, bulk-delete, reset-traffic, delDepleted, reset-all)
so the dialogs render themed. Filter state persists to
localStorage under clientsFilterState. Sort + pagination state is
local; pageSize seeds from /panel/setting/defaultSettings.
The four modals share a controlled "open/onOpenChange" pattern
that replaces vue's v-model:open. ClientFormModal computes
attach/detach diffs from the inbound multi-select on submit; the
parent's onSave callback routes them through useClients's attach()/
detach() after the main update succeeds.
ESLint config: turned off four react-hooks v7 rules
(react-compiler, preserve-manual-memoization, set-state-in-effect,
purity). They're all React-Compiler-driven informational rules; we
don't run the compiler and the patterns they flag (initial-fetch
useEffect, derived computations using Date.now, inline arrow event
handlers) are all idiomatic React. Disabling globally instead of
per-line keeps the diff readable.
* refactor(frontend): port index dashboard to react+ts
Step 7 of the Vue→React migration. Ports the overview/index entry: dashboard
page, status + xray cards, panel-update / log / backup / system-history /
xray-metrics / xray-log / version modals, and the custom-geo subsection. Adds
the shared JsonEditor (CodeMirror 6) and useStatus hook used by the config
modal. Removes the unused react-hooks/set-state-in-effect disables now that
the rule is off globally.
* refactor(frontend): port xray to react+ts
Step 8 of the Vue→React migration. Ports the xray config entry: page shell,
basics/routing/outbounds/balancers/dns tabs, the rule + balancer + dns server
+ dns presets + warp + nord modals, the protocol-aware outbound form, and the
shared FinalMaskForm (TCP/UDP masks + QUIC params). Adds useXraySetting that
mirrors the legacy two-way sync between the JSON template string and the
parsed templateSettings tree. The outbound model itself stays in JS so the
class-driven form keeps its existing mutation API; instance access is typed
loosely inside the form to match.
The shared FinalMaskForm.vue and JsonEditor.vue stay alongside the new .tsx
versions until step 9 — InboundFormModal.vue still imports them.
Adds react-hooks/immutability and react-hooks/refs to the already-disabled
react-compiler rule set; both flag the outbound form's instance-mutation
pattern that doesn't run through useState.
* Upgrade frontend deps (antd v6, i18n, TS)
Bump frontend dependencies in package.json and regenerate package-lock.json. Notable updates: upgrade antd to v6, update i18next/react-i18next, axios, qs, vue-i18n, TypeScript and ESLint, plus related @rc-component packages and replacements (e.g. classnames/rc-util -> clsx/@rc-component/util). Lockfile changes reflect the new dependency tree required for Ant Design v6 and other package upgrades.
* refactor(frontend): port inbounds to react+ts and drop vue toolchain
Step 9 — the last entry. Ports the inbounds entry: page shell, list with
desktop table + mobile cards, info modal, qr-code modal, share-link
helpers, and the protocol-aware form modal (basics / protocol /
stream / security / sniffing / advanced JSON). useInbounds replaces
the Vue composable with WebSocket-driven traffic + client-stats merge.
Inbound and DBInbound models stay in JS so the class-driven form keeps
its mutation API; instance access is typed loosely inside the form to
match. FinalMaskForm/JsonEditor/TextModal/PromptModal/InfinityIcon are
the last shared bits to flip; their .vue counterparts go too.
Toolchain cleanup now that no entry needs Vue: drop plugin-vue from
vite.config, remove the .vue lint block + parser, prune vue / vue-i18n
/ ant-design-vue / @ant-design/icons-vue / vue3-persian-datetime-picker
/ moment-jalaali override from package.json, and switch utils/index.js
to import { message } from 'antd' instead of ant-design-vue.
* chore(frontend): adopt antd v6 api updates
Sweep deprecated props across the React tree:
- Modal: destroyOnClose -> destroyOnHidden, maskClosable -> mask.closable
- Space: direction -> orientation (or removed when redundant)
- Input.Group compact -> Space.Compact block
- Drawer: width -> size
- Spin: tip -> description
- Progress: trailColor -> railColor
- Alert: message -> title
- Popover: overlayClassName -> rootClassName
- BackTop -> FloatButton.BackTop
Also refresh dashboard theming for v6: rename dark/ultra Layout and Menu
tokens (siderBg, darkItemBg, darkSubMenuItemBg, darkPopupBg), tweak gauge
size/stroke, add font-size overrides for Statistic and Progress so the
overview numbers stay legible under v6 defaults.
* chore(frontend): antd v6 polish, theme + modal fixes
- adopt message.useMessage hook + messageBus bridge so HttpUtil messages
inherit ConfigProvider theme tokens
- replace deprecated antd APIs (List, Input addonBefore/After, Empty
imageStyle); introduce InputAddon helper + SettingListItem custom rows
- fix dark/ultra selectors in portaled modals (body.dark,
html[data-theme='ultra-dark']) instead of nonexistent .is-dark/.is-ultra
- add horizontal scroll to clients table; reorder node columns so
actions+enable sit at the left
- swap raw button for antd Button in NodeFormModal test connection
- fix FinalMaskForm nested-form by hoisting it outside OutboundFormModal's
parent Form
- fix advanced "all" JSON tab in InboundFormModal — useMemo on a mutated
ref was stale; compute on every render
- fix chart-on-open for SystemHistory + XrayMetrics modals by adding open
to effect deps (useRef.current doesn't trigger re-runs)
- switch i18next interpolation to single-brace {var} to match locale files
- drop residual Vue mentions in CI workflows and Go comments
* fix(frontend): qr code collapse — open only first panel, allow toggle
ClientQrModal and QrCodeModal both used activeKey without onChange,
forcing every panel open and blocking user toggle. Switch to controlled
state initialized to the first item's key on open, with onChange so
clicks update state.
Also remove unused AppBridge.tsx (superseded by per-page message.useMessage
hook).
* fix(frontend): hover cards, balancer load, routing dnd, modal a11y, outbound crash
- ClientsPage/SettingsPage/XrayPage: add hoverable to bottom card/tabs so
hover affordance matches the top card
- BalancerFormModal: lazy-init useState from props + destroyOnHidden so
the form mounts with saved values instead of relying on a useEffect
sync that could miss the first open
- RoutingTab: rewrite pointer drag — handlers are now defined inside the
pointerdown closure so addEventListener/removeEventListener match;
drag state lives on a ref (from/to/moved) so onUp reads the real
indices, not stale closure values. Adds setPointerCapture so Windows
and touch keep delivering events when the cursor leaves the handle.
- OutboundFormModal/InboundFormModal: blur the focused input before
switching tabs to silence the aria-hidden-on-focused-element warning
- utils.isArrEmpty: return true for undefined/null arrays — the old form
treated undefined as "not empty" which crashed VLESSSettings.fromJson
when json.vnext was missing
* fix(frontend): clipboard reliability + restyle login page
- ClipboardManager.copyText: prefer navigator.clipboard on secure
contexts, fall back to a focused on-screen textarea + execCommand.
Old path used left:-9999px which failed selection in some browsers
and swallowed execCommand's return value, so the "copied" toast
appeared even when nothing made it to the clipboard.
- LoginPage: richer gradient backdrop — five animated colour blobs,
glassmorphic card (backdrop-filter blur + saturate), gradient brand
text/accent, masked grid texture for depth, and a thin gradient
border on the card. Light/dark/ultra each get their own palette.
* Memoize compactAdvancedJson and update deps
Wrap compactAdvancedJson in useCallback (dependent on messageApi) and add it to the dependency array of applyAdvancedJsonToBasic. This ensures a stable function reference for correct dependency tracking and avoids stale closures/unnecessary re-renders in InboundFormModal.tsx.
* style(frontend): prettier charts, drop redundant frame, format net rates
- Sparkline: multi-stop gradient fill, soft drop-shadow under the line,
dashed grid, glowing pulse on the latest-point marker, pill-shaped
tooltip with dashed crosshair
- XrayMetricsModal: glow + pulse on the observatory alive dot,
monospace stamps/listen text
- SystemHistoryModal: keep just the modal's frame around the chart (the
inner wrapper I'd added stacked a second border on top); strip the
decimal from Net Up/Down (25.63 KB/s → 25 KB/s) only on this chart's
formatter
* style(frontend): refined dark/ultra palette + shared pro card frame
- Dark tokens shifted to a cooler, Linear-style palette: page #1a1b1f,
sidebar/header #15161a (recessed nav, darker than cards), card
#23252b, elevated #2d2f37
- Ultra dark: page pure #000 for OLED, sidebar #050507 disappears into
the frame, card #101013 with a clear step, elevated #1a1a1e
- New styles/page-cards.css holds the card border/shadow/hover rules so
all seven content pages (index, clients, inbounds, xray, settings,
nodes, api-docs) share one definition instead of duplicating in each
page CSS
- Dashboard typography: uppercase card titles with letter-spacing,
larger 17px stat values, subtle gradient divider between stat columns,
ellipsis on action labels so "Backup & Restore" doesn't break the
card height at mid widths
- Light --bg-page stays at #e6e8ec for the contrast against white cards
* fix(frontend): wireguard info alignment, blue login dark, embed gitkeep
- align WireGuard info-modal fields with Protocol/Address/Port by wrapping
values in Tag (matches the rest of the dl.info-list rows)
- swap login dark palette from purple to pure blue blobs/accent/brand
- pin web/dist/.gitkeep through gitignore so //go:embed all:dist never
fails on a fresh clone with an empty dist directory
* docs: refresh frontend docs for the React + TS + AntD 6 stack
Update CONTRIBUTING.md and frontend/README.md to describe the migrated
frontend accurately:
- replace Vue 3 / Ant Design Vue 4 references with React 19 / AntD 6 / TS
- swap composables -> hooks, vue-i18n -> react-i18next, createApp -> createRoot
- mention the typecheck step (tsc --noEmit) in the PR checklist
- document the Vite 8.0.13 pin and TypeScript strict mode in conventions
- list the nodes and api-docs entries that were missing from the layout
* style(frontend): improve readability and mobile polish
- bump statistic title/value contrast in dark and ultra-dark so totals
on the inbounds summary card stay legible
- give index card actions explicit colors per theme so links like Stop,
Logs, System History no longer fade into the card background
- show the panel version as a tag next to "3X-UI" on mobile, mirroring
the Xray version tag pattern, and turn it orange when an update is
available
- make the login settings button a proper circle by adding size="large"
+ an explicit border-radius fallback on .toolbar-btn
* feat: jalali calendar support and date formatting fixes
- Wire useDatepicker into IntlUtil and switch jalalian display locale
to fa-IR for clean "1405/07/03 12:00:00" output (drops the awkward
"AP" era suffix that "<lang>-u-ca-persian" produced)
- Drop in persian-calendar-suite for the jalali date picker, with a
light/dark/ultra theme map and CSS overrides so the inline-styled
input stays readable and bg matches the surrounding container
- Force LTR on the picker input so "1405/03/07 00:00" reads naturally
- Pass calendar setting through ClientInfoModal, ClientsPage Duration
tooltip, and ClientFormModal's expiry picker
- Heuristic toMs() in ClientInfoModal so GORM's autoUpdateTime seconds
render as a real date instead of "1348/11/01"
- Persist UpdatedAt on the ClientRecord row in client_service.Update;
previously only the inbound settings JSON was bumped, so the panel
never saw a fresh updated_at after editing a client
* feat(frontend): donate link, panel version label, login lang menu
- Sidebar: add heart donate link to https://donate.sanaei.dev and small panel version under 3X-UI brand
- Login: swap settings-cog for translation icon, drop title, render languages as a direct list
- Vite dev: inject window.X_UI_CUR_VER from config/version so dev mode matches prod
- Translations: add menu.donate across all locales
* fix(xray-update): respect XUI_BIN_FOLDER on Windows
The Windows update path hardcoded "bin/xray-windows-amd64.exe", ignoring
the configured XUI_BIN_FOLDER. In dev mode (folder set to x-ui) this
created a stray bin/ folder while the running binary stayed un-updated.
* Bump Xray to v26.5.9 and minor cleanup
Update Xray release URLs to v26.5.9 in the GitHub Actions workflow and DockerInit.sh. Remove the hardcoded skip for tagVersion "26.5.3" so it will be considered when collecting Xray versions. Apply small formatting fixes: remove an extra blank line in database/db.go, normalize spacing/alignment of Protocol constants in database/model/model.go, and trim a trailing blank line in web/controller/inbound.go.
* fix(frontend): route remaining copy buttons through ClipboardManager
Direct navigator.clipboard calls fail in non-secure contexts (HTTP on a
LAN IP), making the API-docs code copy and security-tab token copy
silently broken. Both now go through ClipboardManager which falls back
to document.execCommand('copy') when navigator.clipboard is unavailable.
* fix(db): store CreatedAt/UpdatedAt in milliseconds
GORM's autoCreateTime/autoUpdateTime tags default to Unix seconds on
int64 fields and overwrite the service-supplied UnixMilli value on
save. The frontend interprets these timestamps as JS Date inputs
(milliseconds), so created/updated columns rendered ~1970 dates. Adding
the :milli qualifier makes GORM match what the service code and UI
expect.
* Improve legacy clipboard copy handling
Refactor ClipboardManager._legacyCopy to better handle focus and selection when copying. The textarea is now appended to the active element's parent (or body) and placed off-screen with aria-hidden and readonly attributes. The code preserves and restores the previous document selection and active element, uses focus({preventScroll: true}) to avoid scrolling, and returns the execCommand('copy') result. This makes legacy copy behavior more robust and less disruptive to the page state.
* fix(lint): drop redundant ok=false in clipboard fallback catch
* chore(deps): bump golang.org/x/net to v0.55.0 for GO-2026-5026
2026-05-23 13:21:45 +00:00
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useCallback , useEffect , useMemo , useRef , useState } from 'react' ;
import { useTranslation } from 'react-i18next' ;
import dayjs , { type Dayjs } from 'dayjs' ;
import {
Button ,
Card ,
Checkbox ,
Col ,
Divider ,
Empty ,
Form ,
Input ,
InputNumber ,
Modal ,
Radio ,
Row ,
Select ,
Space ,
Switch ,
Tabs ,
Tooltip ,
Typography ,
message ,
} from 'antd' ;
import {
SyncOutlined ,
PlusOutlined ,
MinusOutlined ,
DeleteOutlined ,
CaretUpOutlined ,
CaretDownOutlined ,
SettingOutlined ,
} from '@ant-design/icons' ;
import {
HttpUtil ,
RandomUtil ,
NumberFormatter ,
SizeFormatter ,
Wireguard ,
} from '@/utils' ;
import InputAddon from '@/components/InputAddon' ;
import { getRandomRealityTarget } from '@/models/reality-targets' ;
import {
Inbound ,
Protocols ,
SSMethods ,
SNIFFING_OPTION ,
TLS_VERSION_OPTION ,
TLS_CIPHER_OPTION ,
UTLS_FINGERPRINT ,
ALPN_OPTION ,
USAGE_OPTION ,
DOMAIN_STRATEGY_OPTION ,
TCP_CONGESTION_OPTION ,
MODE_OPTION ,
} from '@/models/inbound.js' ;
import { DBInbound } from '@/models/dbinbound.js' ;
import FinalMaskForm from '@/components/FinalMaskForm' ;
import DateTimePicker from '@/components/DateTimePicker' ;
import JsonEditor from '@/components/JsonEditor' ;
Reduce list-page payloads with slim/paged endpoints (#4500)
* perf(inbounds): slim list payload + lazy hydrate for row actions
Adds GET /panel/api/inbounds/list/slim that returns the same list shape
but strips every per-client field besides email/enable/comment from
settings.clients[] and skips UUID/SubId enrichment on ClientStats.
The inbounds page only reads those three to compute its client counters
and badges, so the slim variant trims tens of bytes per client (uuid,
password, flow, security, totalGB, expiryTime, limitIp, tgId, ...).
On a panel with thousands of clients this is the dominant load-time
cost.
Detail flows (edit / info / qr / export / clone) call /get/:id through
a new hydrateInbound helper before opening — the slim list view never
needs the secrets it doesn't render.
* perf(clients): server-side pagination + slim row payload
Adds GET /panel/api/clients/list/paged that filters, sorts, and paginates
on the server, returns a slim row shape (drops uuid/password/auth/flow/
security/reverse/tgId per client), and includes a stable summary
(total, active, online[], depleted[], expiring[], deactive[]) computed
across the full DB row set so the dashboard cards don't change as the
user paginates or filters. Page size capped at 200.
useClients now exposes { clients (current page), total, filtered, query,
setQuery, summary, hydrate }. ClientsPage feeds its filter/sort/page
state into setQuery via a single effect, debounces search by 300ms, and
hydrates the full client record via /get/:email before opening edit/info/
qr modals. Local filter/sort logic and the all-clients summary memo are
gone.
On a 2000-client panel this turns the initial response from ~MB to ~25 row
slice (~10s of KB) and removes the all-client parse cost from every
refresh.
* perf(settings): use /inbounds/options for LDAP tag picker
The General settings tab only needs each inbound's tag/protocol/port to
fill a dropdown but was calling /panel/api/inbounds/list which ships the
full settings JSON with every embedded client. Switched it to /options
and added Tag to the projection. On a panel with thousands of clients
this drops the General-tab load payload from megabytes to a tiny
per-inbound row each.
* perf(clients): de-duplicate options + paged list fetches
Two issues caused each clients-page load to fire its requests twice:
1. setQuery in the hook took whatever object the consumer passed and
stored it as-is. The consumer (ClientsPage) constructs a new object
literal in an effect, so even when nothing actually changed the ref
was new — the hook's useEffect saw a new query and re-fetched.
Wrapped setQuery with a shallow value compare so identical params
are a no-op.
2. The picker /inbounds/options fetch was bundled into refresh() with a
length==0 guard, but the two back-to-back refreshes both saw an
empty inbounds array (the first hadn't resolved yet) so both fired
the request. Moved the options fetch into its own one-shot effect.
* perf(inbounds): share nodes list with form modal instead of refetching
InboundsPage and InboundFormModal both called useNodes() — each
instance maintains its own state and fires its own /panel/api/nodes/list
fetch on mount. Since the modal is always rendered (open or not), every
page load hit the endpoint twice.
Threaded nodes from the page through an availableNodes prop on the form
modal so they share one fetch.
* docs(api): register /clients/list/paged endpoint
TestAPIRoutesDocumented was failing because the new paginated clients
endpoint added in this branch wasn't listed in endpoints.js.
2026-05-23 15:43:43 +00:00
import type { NodeRecord } from '@/hooks/useNodes' ;
Frontend rewrite: React + TypeScript with AntD v6 (#4498)
* chore(frontend): add react+typescript toolchain alongside vue
Step 0 of the planned vue->react migration. React 19, antd 5, i18next
+ react-i18next, typescript 5, and @vitejs/plugin-react 6 are added as
dev/runtime deps alongside the existing vue stack. Both frameworks
coexist in the build until the last entry flips.
* vite.config.js: react() plugin runs next to vue(); new manualChunks
for vendor-react / vendor-antd-react / vendor-icons-react /
vendor-i18next. Existing vue chunks unchanged.
* eslint.config.js: typescript-eslint + eslint-plugin-react-hooks
rules scoped to *.{ts,tsx}; vue config untouched for *.{js,vue}.
* tsconfig.json: strict, jsx: react-jsx, moduleResolution: bundler,
allowJs: true (lets .tsx files import the remaining .js modules
during incremental migration), @/* path alias.
* env.d.ts: Vite client types + window.X_UI_BASE_PATH typing +
SubPageData shape consumed by the subscription page.
Vite stays pinned at 8.0.13 per the existing project policy. No
existing .vue/.js source files touched in this step.
eslint-plugin-react (not -hooks) is not included because its latest
release does not yet support ESLint 10. react-hooks/purity covers
the safety-critical case; revisit when the plugin updates.
* refactor(frontend): port subpage to react+ts
Step 1 of the planned vue->react migration. The standalone
subscription page (sub/sub.go renders the HTML host; React mounts
into #app) is the first entry off vue.
Introduces two shared pieces both entries (and future ones) will
use:
* src/hooks/useTheme.tsx — React Context + useTheme hook + the
same buildAntdThemeConfig (dark/ultra-dark token overrides) and
pauseAnimationsUntilLeave helper the vue version exposes. Same
localStorage keys (dark-mode, isUltraDarkThemeEnabled) and DOM
side effects (body.className, html[data-theme]) so the two stay
in sync across the coexistence period.
* src/i18n/react.ts — i18next + react-i18next loader that reads
the same web/translation/*.json files via import.meta.glob. The
vue-i18n setup in src/i18n/index.js is untouched and still serves
the remaining vue entries.
SubPage.tsx mirrors the vue version's behavior: reads
window.__SUB_PAGE_DATA__ injected by the Go sub server, renders QR
codes / descriptions / Android+iOS deep-link dropdowns, supports
theme cycle and language switch. Uses AntD v5 idioms: Descriptions
items prop, Dropdown menu prop, Layout.Content.
* refactor(frontend): port login to react+ts
Step 2 of the planned vue->react migration. The login entry is the
first to exercise AntD React's Form API (Form + Form.Item with
name/rules + onFinish) and the existing axios/CSRF interceptors
under React.
* LoginPage.tsx: same form fields, conditional 2FA input,
rotating headline ("Hello" / "Welcome to..."), drifting blob
background, theme cycle + language popover. Headline transition
switches from vue's <Transition mode=out-in> to a CSS keyframe
animation keyed off the visible word.
* entries/login.tsx: setupAxios() + applyDocumentTitle() unchanged
from the vue entry — both are framework-agnostic in src/utils
and src/api/axios-init.js.
useTheme hook, ThemeProvider, and i18n/react.ts loader introduced
in step 1 are now shared across two entries; Vite extracts them as
a small chunk in the build output.
* refactor(frontend): port api-docs to react+ts
Step 3 of the planned vue->react migration. The five api-docs files
(ApiDocsPage, CodeBlock, EndpointRow, EndpointSection, plus the
data-only endpoints.js) all move to react+ts.
Also introduces components/AppSidebar.tsx — api-docs is the first
authenticated page to need it. AppSidebar.vue stays in place for the
six remaining vue entries (settings, inbounds, clients, xray, nodes,
index); each gets switched to AppSidebar.tsx as its entry migrates.
After the last entry flips, AppSidebar.vue is deleted.
Notable transformations:
* The scroll observer that highlights the active TOC link is a
useEffect keyed on sections — re-registers whenever the visible
set changes (search filter narrows it). Same behaviour as the vue
watchEffect.
* v-html="safeInlineHtml(...)" becomes
dangerouslySetInnerHTML={{ __html: safeInlineHtml(...) }}. The
helper still escapes everything except <code> tags.
* JSON syntax highlighter in CodeBlock is unchanged — pure regex on
the escaped string, then rendered via dangerouslySetInnerHTML.
* endpoints.js stays as JS (allowJs in tsconfig); only the consumer
signatures (Endpoint, Section) are typed at the React boundary.
* AppSidebar reuses pauseAnimationsUntilLeave + useTheme from
step 1. Drawer + Sider keyed off the same localStorage flag
(isSidebarCollapsed) and DOM theme attributes the vue version
uses, so the two stay in sync during coexistence.
* refactor(frontend): port nodes to react+ts
Step 4 of the planned vue->react migration. The nodes entry brings in
the largest shared-infrastructure batch so far — every authenticated
react page from here on can lean on these.
New shared pieces (live alongside their .vue counterparts during
coexistence):
* hooks/useMediaQuery.ts — useState + resize listener
* hooks/useWebSocket.ts — wraps WebSocketClient, subscribes on mount
and unsubscribes on unmount. The underlying client is a single
module-level instance so multiple components on the same page
share one socket.
* hooks/useNodes.ts — node list state + CRUD + probe/test, including
the totals memo (online/offline/avgLatency) used by the summary card.
applyNodesEvent is the entry point for the heartbeat-pushed list.
* components/CustomStatistic.tsx — thin Statistic wrapper, prefix +
suffix slots become props.
* components/Sparkline.tsx — the SVG line chart with measured-width
axis scaling, gradient fill, tooltip overlay, and per-instance
gradient id from React.useId. ResizeObserver lifecycle is in
useEffect; the math is unchanged.
Pages:
* NodesPage — wires hooks + WebSocket together, renders summary card
+ NodeList, hosts the form modal. Uses Modal.useModal() for the
delete confirm so the dialog inherits ConfigProvider theming.
* NodeList — desktop renders a Table with expandable history rows;
mobile flips to a vertical card list whose actions live in a
bottom-right Dropdown. The IP-blur eye toggle persists across both.
* NodeFormModal — controlled form (useState object, single setForm
per change). The reset-on-open effect computes the next state
once and applies it with eslint-disable to satisfy the new
react-hooks/set-state-in-effect rule on a legitimate pattern.
* NodeHistoryPanel — polls /panel/api/nodes/history/{id}/{metric}/
{bucket} every 15s, renders cpu+mem sparklines side-by-side.
* refactor(frontend): port settings to react+ts
Step 5 of the planned vue->react migration. Settings is the first
entry whose state model didn't translate to the Vue-style "parent
passes a reactive object, children mutate it in place" pattern, so
the React port flips it to lifted state + a typed updateSetting
patch function.
* models/setting.ts — typed AllSetting class with the same field
defaults and equals() behavior the vue version had. The .js
twin is deleted; nothing else imported it.
* hooks/useAllSetting.ts — owns allSetting + oldAllSetting state,
exposes updateSetting(patch), saveDisabled is derived via useMemo
off equals() (no more 1Hz dirty-check timer).
* components/SettingListItem.tsx — children-based wrapper instead
of named slots. The vue twin stays alive because xray (BasicsTab,
DnsTab) still imports it; deleted when xray migrates.
The five tab components and the TwoFactorModal each accept
{ allSetting, updateSetting } and render with AntD v5's Collapse
items[] API. Every v-model:value="x" became
value={...} onChange={(e) => updateSetting({ key: e.target.value })}
or onChange={(v) => updateSetting({ key: v })} for non-input
controls.
SubscriptionFormatsTab is the trickiest — fragment / noises[] /
mux / direct routing rules are stored as JSON-encoded strings on
the wire. Parsing them once via useMemo per field, mutating the
parsed object on edit, and stringifying back into the patch keeps
the round-trip identical to the vue version.
SettingsPage hosts the tab navigation (with hash sync), the
save / restart action bar, the security-warnings alert banner,
and the restart flow that rebuilds the panel URL after the new
host/port/cert settings take effect.
* refactor(frontend): port clients to react+ts
Step 6 of the planned vue->react migration. Clients is the biggest
data-CRUD page in the panel (1.1k-line ClientsPage, 4 modals, full
table + mobile card list, WebSocket-driven realtime traffic + online
updates).
New shared infra (lives alongside vue twins until inbounds migrates):
* hooks/useClients.ts — clients + inbounds list, CRUD + bulk delete +
attach/detach + traffic reset, with WebSocket event handlers
(traffic, client_stats, invalidate) and a small debounced refresh
on the invalidate event. State managed via setState; the live
client_stats event merges traffic snapshots row-by-row through a
ref to avoid stale closure issues.
* hooks/useDatepicker.ts — singleton "gregorian"/"jalalian" cache
with subscribe/notify so multiple components can read the panel's
Calendar Type without re-fetching. Mirrors useDatepicker.js.
* components/DateTimePicker.tsx — AntD DatePicker wrapper.
vue3-persian-datetime-picker has no React port; the Jalali UI
calendar is deferred (read-only Jalali display via IntlUtil
formatDate still works). The vue twin stays for inbounds.
* pages/inbounds/QrPanel.tsx — copy/download/copy-as-png QR helper
shared between clients (qr modal) and inbounds (still on vue).
Vue twin stays alive at QrPanel.vue.
* models/inbound.ts — slim port: only the TLS_FLOW_CONTROL constant
the clients form needs. The full inbound model stays as
inbound.js for now; inbounds will pull it in as inbound.ts.
The clients page itself uses Modal.useModal() for all confirm
dialogs (delete, bulk-delete, reset-traffic, delDepleted, reset-all)
so the dialogs render themed. Filter state persists to
localStorage under clientsFilterState. Sort + pagination state is
local; pageSize seeds from /panel/setting/defaultSettings.
The four modals share a controlled "open/onOpenChange" pattern
that replaces vue's v-model:open. ClientFormModal computes
attach/detach diffs from the inbound multi-select on submit; the
parent's onSave callback routes them through useClients's attach()/
detach() after the main update succeeds.
ESLint config: turned off four react-hooks v7 rules
(react-compiler, preserve-manual-memoization, set-state-in-effect,
purity). They're all React-Compiler-driven informational rules; we
don't run the compiler and the patterns they flag (initial-fetch
useEffect, derived computations using Date.now, inline arrow event
handlers) are all idiomatic React. Disabling globally instead of
per-line keeps the diff readable.
* refactor(frontend): port index dashboard to react+ts
Step 7 of the Vue→React migration. Ports the overview/index entry: dashboard
page, status + xray cards, panel-update / log / backup / system-history /
xray-metrics / xray-log / version modals, and the custom-geo subsection. Adds
the shared JsonEditor (CodeMirror 6) and useStatus hook used by the config
modal. Removes the unused react-hooks/set-state-in-effect disables now that
the rule is off globally.
* refactor(frontend): port xray to react+ts
Step 8 of the Vue→React migration. Ports the xray config entry: page shell,
basics/routing/outbounds/balancers/dns tabs, the rule + balancer + dns server
+ dns presets + warp + nord modals, the protocol-aware outbound form, and the
shared FinalMaskForm (TCP/UDP masks + QUIC params). Adds useXraySetting that
mirrors the legacy two-way sync between the JSON template string and the
parsed templateSettings tree. The outbound model itself stays in JS so the
class-driven form keeps its existing mutation API; instance access is typed
loosely inside the form to match.
The shared FinalMaskForm.vue and JsonEditor.vue stay alongside the new .tsx
versions until step 9 — InboundFormModal.vue still imports them.
Adds react-hooks/immutability and react-hooks/refs to the already-disabled
react-compiler rule set; both flag the outbound form's instance-mutation
pattern that doesn't run through useState.
* Upgrade frontend deps (antd v6, i18n, TS)
Bump frontend dependencies in package.json and regenerate package-lock.json. Notable updates: upgrade antd to v6, update i18next/react-i18next, axios, qs, vue-i18n, TypeScript and ESLint, plus related @rc-component packages and replacements (e.g. classnames/rc-util -> clsx/@rc-component/util). Lockfile changes reflect the new dependency tree required for Ant Design v6 and other package upgrades.
* refactor(frontend): port inbounds to react+ts and drop vue toolchain
Step 9 — the last entry. Ports the inbounds entry: page shell, list with
desktop table + mobile cards, info modal, qr-code modal, share-link
helpers, and the protocol-aware form modal (basics / protocol /
stream / security / sniffing / advanced JSON). useInbounds replaces
the Vue composable with WebSocket-driven traffic + client-stats merge.
Inbound and DBInbound models stay in JS so the class-driven form keeps
its mutation API; instance access is typed loosely inside the form to
match. FinalMaskForm/JsonEditor/TextModal/PromptModal/InfinityIcon are
the last shared bits to flip; their .vue counterparts go too.
Toolchain cleanup now that no entry needs Vue: drop plugin-vue from
vite.config, remove the .vue lint block + parser, prune vue / vue-i18n
/ ant-design-vue / @ant-design/icons-vue / vue3-persian-datetime-picker
/ moment-jalaali override from package.json, and switch utils/index.js
to import { message } from 'antd' instead of ant-design-vue.
* chore(frontend): adopt antd v6 api updates
Sweep deprecated props across the React tree:
- Modal: destroyOnClose -> destroyOnHidden, maskClosable -> mask.closable
- Space: direction -> orientation (or removed when redundant)
- Input.Group compact -> Space.Compact block
- Drawer: width -> size
- Spin: tip -> description
- Progress: trailColor -> railColor
- Alert: message -> title
- Popover: overlayClassName -> rootClassName
- BackTop -> FloatButton.BackTop
Also refresh dashboard theming for v6: rename dark/ultra Layout and Menu
tokens (siderBg, darkItemBg, darkSubMenuItemBg, darkPopupBg), tweak gauge
size/stroke, add font-size overrides for Statistic and Progress so the
overview numbers stay legible under v6 defaults.
* chore(frontend): antd v6 polish, theme + modal fixes
- adopt message.useMessage hook + messageBus bridge so HttpUtil messages
inherit ConfigProvider theme tokens
- replace deprecated antd APIs (List, Input addonBefore/After, Empty
imageStyle); introduce InputAddon helper + SettingListItem custom rows
- fix dark/ultra selectors in portaled modals (body.dark,
html[data-theme='ultra-dark']) instead of nonexistent .is-dark/.is-ultra
- add horizontal scroll to clients table; reorder node columns so
actions+enable sit at the left
- swap raw button for antd Button in NodeFormModal test connection
- fix FinalMaskForm nested-form by hoisting it outside OutboundFormModal's
parent Form
- fix advanced "all" JSON tab in InboundFormModal — useMemo on a mutated
ref was stale; compute on every render
- fix chart-on-open for SystemHistory + XrayMetrics modals by adding open
to effect deps (useRef.current doesn't trigger re-runs)
- switch i18next interpolation to single-brace {var} to match locale files
- drop residual Vue mentions in CI workflows and Go comments
* fix(frontend): qr code collapse — open only first panel, allow toggle
ClientQrModal and QrCodeModal both used activeKey without onChange,
forcing every panel open and blocking user toggle. Switch to controlled
state initialized to the first item's key on open, with onChange so
clicks update state.
Also remove unused AppBridge.tsx (superseded by per-page message.useMessage
hook).
* fix(frontend): hover cards, balancer load, routing dnd, modal a11y, outbound crash
- ClientsPage/SettingsPage/XrayPage: add hoverable to bottom card/tabs so
hover affordance matches the top card
- BalancerFormModal: lazy-init useState from props + destroyOnHidden so
the form mounts with saved values instead of relying on a useEffect
sync that could miss the first open
- RoutingTab: rewrite pointer drag — handlers are now defined inside the
pointerdown closure so addEventListener/removeEventListener match;
drag state lives on a ref (from/to/moved) so onUp reads the real
indices, not stale closure values. Adds setPointerCapture so Windows
and touch keep delivering events when the cursor leaves the handle.
- OutboundFormModal/InboundFormModal: blur the focused input before
switching tabs to silence the aria-hidden-on-focused-element warning
- utils.isArrEmpty: return true for undefined/null arrays — the old form
treated undefined as "not empty" which crashed VLESSSettings.fromJson
when json.vnext was missing
* fix(frontend): clipboard reliability + restyle login page
- ClipboardManager.copyText: prefer navigator.clipboard on secure
contexts, fall back to a focused on-screen textarea + execCommand.
Old path used left:-9999px which failed selection in some browsers
and swallowed execCommand's return value, so the "copied" toast
appeared even when nothing made it to the clipboard.
- LoginPage: richer gradient backdrop — five animated colour blobs,
glassmorphic card (backdrop-filter blur + saturate), gradient brand
text/accent, masked grid texture for depth, and a thin gradient
border on the card. Light/dark/ultra each get their own palette.
* Memoize compactAdvancedJson and update deps
Wrap compactAdvancedJson in useCallback (dependent on messageApi) and add it to the dependency array of applyAdvancedJsonToBasic. This ensures a stable function reference for correct dependency tracking and avoids stale closures/unnecessary re-renders in InboundFormModal.tsx.
* style(frontend): prettier charts, drop redundant frame, format net rates
- Sparkline: multi-stop gradient fill, soft drop-shadow under the line,
dashed grid, glowing pulse on the latest-point marker, pill-shaped
tooltip with dashed crosshair
- XrayMetricsModal: glow + pulse on the observatory alive dot,
monospace stamps/listen text
- SystemHistoryModal: keep just the modal's frame around the chart (the
inner wrapper I'd added stacked a second border on top); strip the
decimal from Net Up/Down (25.63 KB/s → 25 KB/s) only on this chart's
formatter
* style(frontend): refined dark/ultra palette + shared pro card frame
- Dark tokens shifted to a cooler, Linear-style palette: page #1a1b1f,
sidebar/header #15161a (recessed nav, darker than cards), card
#23252b, elevated #2d2f37
- Ultra dark: page pure #000 for OLED, sidebar #050507 disappears into
the frame, card #101013 with a clear step, elevated #1a1a1e
- New styles/page-cards.css holds the card border/shadow/hover rules so
all seven content pages (index, clients, inbounds, xray, settings,
nodes, api-docs) share one definition instead of duplicating in each
page CSS
- Dashboard typography: uppercase card titles with letter-spacing,
larger 17px stat values, subtle gradient divider between stat columns,
ellipsis on action labels so "Backup & Restore" doesn't break the
card height at mid widths
- Light --bg-page stays at #e6e8ec for the contrast against white cards
* fix(frontend): wireguard info alignment, blue login dark, embed gitkeep
- align WireGuard info-modal fields with Protocol/Address/Port by wrapping
values in Tag (matches the rest of the dl.info-list rows)
- swap login dark palette from purple to pure blue blobs/accent/brand
- pin web/dist/.gitkeep through gitignore so //go:embed all:dist never
fails on a fresh clone with an empty dist directory
* docs: refresh frontend docs for the React + TS + AntD 6 stack
Update CONTRIBUTING.md and frontend/README.md to describe the migrated
frontend accurately:
- replace Vue 3 / Ant Design Vue 4 references with React 19 / AntD 6 / TS
- swap composables -> hooks, vue-i18n -> react-i18next, createApp -> createRoot
- mention the typecheck step (tsc --noEmit) in the PR checklist
- document the Vite 8.0.13 pin and TypeScript strict mode in conventions
- list the nodes and api-docs entries that were missing from the layout
* style(frontend): improve readability and mobile polish
- bump statistic title/value contrast in dark and ultra-dark so totals
on the inbounds summary card stay legible
- give index card actions explicit colors per theme so links like Stop,
Logs, System History no longer fade into the card background
- show the panel version as a tag next to "3X-UI" on mobile, mirroring
the Xray version tag pattern, and turn it orange when an update is
available
- make the login settings button a proper circle by adding size="large"
+ an explicit border-radius fallback on .toolbar-btn
* feat: jalali calendar support and date formatting fixes
- Wire useDatepicker into IntlUtil and switch jalalian display locale
to fa-IR for clean "1405/07/03 12:00:00" output (drops the awkward
"AP" era suffix that "<lang>-u-ca-persian" produced)
- Drop in persian-calendar-suite for the jalali date picker, with a
light/dark/ultra theme map and CSS overrides so the inline-styled
input stays readable and bg matches the surrounding container
- Force LTR on the picker input so "1405/03/07 00:00" reads naturally
- Pass calendar setting through ClientInfoModal, ClientsPage Duration
tooltip, and ClientFormModal's expiry picker
- Heuristic toMs() in ClientInfoModal so GORM's autoUpdateTime seconds
render as a real date instead of "1348/11/01"
- Persist UpdatedAt on the ClientRecord row in client_service.Update;
previously only the inbound settings JSON was bumped, so the panel
never saw a fresh updated_at after editing a client
* feat(frontend): donate link, panel version label, login lang menu
- Sidebar: add heart donate link to https://donate.sanaei.dev and small panel version under 3X-UI brand
- Login: swap settings-cog for translation icon, drop title, render languages as a direct list
- Vite dev: inject window.X_UI_CUR_VER from config/version so dev mode matches prod
- Translations: add menu.donate across all locales
* fix(xray-update): respect XUI_BIN_FOLDER on Windows
The Windows update path hardcoded "bin/xray-windows-amd64.exe", ignoring
the configured XUI_BIN_FOLDER. In dev mode (folder set to x-ui) this
created a stray bin/ folder while the running binary stayed un-updated.
* Bump Xray to v26.5.9 and minor cleanup
Update Xray release URLs to v26.5.9 in the GitHub Actions workflow and DockerInit.sh. Remove the hardcoded skip for tagVersion "26.5.3" so it will be considered when collecting Xray versions. Apply small formatting fixes: remove an extra blank line in database/db.go, normalize spacing/alignment of Protocol constants in database/model/model.go, and trim a trailing blank line in web/controller/inbound.go.
* fix(frontend): route remaining copy buttons through ClipboardManager
Direct navigator.clipboard calls fail in non-secure contexts (HTTP on a
LAN IP), making the API-docs code copy and security-tab token copy
silently broken. Both now go through ClipboardManager which falls back
to document.execCommand('copy') when navigator.clipboard is unavailable.
* fix(db): store CreatedAt/UpdatedAt in milliseconds
GORM's autoCreateTime/autoUpdateTime tags default to Unix seconds on
int64 fields and overwrite the service-supplied UnixMilli value on
save. The frontend interprets these timestamps as JS Date inputs
(milliseconds), so created/updated columns rendered ~1970 dates. Adding
the :milli qualifier makes GORM match what the service code and UI
expect.
* Improve legacy clipboard copy handling
Refactor ClipboardManager._legacyCopy to better handle focus and selection when copying. The textarea is now appended to the active element's parent (or body) and placed off-screen with aria-hidden and readonly attributes. The code preserves and restores the previous document selection and active element, uses focus({preventScroll: true}) to avoid scrolling, and returns the execCommand('copy') result. This makes legacy copy behavior more robust and less disruptive to the page state.
* fix(lint): drop redundant ok=false in clipboard fallback catch
* chore(deps): bump golang.org/x/net to v0.55.0 for GO-2026-5026
2026-05-23 13:21:45 +00:00
import './InboundFormModal.css' ;
const { TextArea } = Input ;
const { Text , Paragraph } = Typography ;
interface InboundFormModalProps {
open : boolean ;
onClose : ( ) = > void ;
onSaved : ( ) = > void ;
mode : 'add' | 'edit' ;
dbInbound : any ;
dbInbounds : any [ ] ;
Reduce list-page payloads with slim/paged endpoints (#4500)
* perf(inbounds): slim list payload + lazy hydrate for row actions
Adds GET /panel/api/inbounds/list/slim that returns the same list shape
but strips every per-client field besides email/enable/comment from
settings.clients[] and skips UUID/SubId enrichment on ClientStats.
The inbounds page only reads those three to compute its client counters
and badges, so the slim variant trims tens of bytes per client (uuid,
password, flow, security, totalGB, expiryTime, limitIp, tgId, ...).
On a panel with thousands of clients this is the dominant load-time
cost.
Detail flows (edit / info / qr / export / clone) call /get/:id through
a new hydrateInbound helper before opening — the slim list view never
needs the secrets it doesn't render.
* perf(clients): server-side pagination + slim row payload
Adds GET /panel/api/clients/list/paged that filters, sorts, and paginates
on the server, returns a slim row shape (drops uuid/password/auth/flow/
security/reverse/tgId per client), and includes a stable summary
(total, active, online[], depleted[], expiring[], deactive[]) computed
across the full DB row set so the dashboard cards don't change as the
user paginates or filters. Page size capped at 200.
useClients now exposes { clients (current page), total, filtered, query,
setQuery, summary, hydrate }. ClientsPage feeds its filter/sort/page
state into setQuery via a single effect, debounces search by 300ms, and
hydrates the full client record via /get/:email before opening edit/info/
qr modals. Local filter/sort logic and the all-clients summary memo are
gone.
On a 2000-client panel this turns the initial response from ~MB to ~25 row
slice (~10s of KB) and removes the all-client parse cost from every
refresh.
* perf(settings): use /inbounds/options for LDAP tag picker
The General settings tab only needs each inbound's tag/protocol/port to
fill a dropdown but was calling /panel/api/inbounds/list which ships the
full settings JSON with every embedded client. Switched it to /options
and added Tag to the projection. On a panel with thousands of clients
this drops the General-tab load payload from megabytes to a tiny
per-inbound row each.
* perf(clients): de-duplicate options + paged list fetches
Two issues caused each clients-page load to fire its requests twice:
1. setQuery in the hook took whatever object the consumer passed and
stored it as-is. The consumer (ClientsPage) constructs a new object
literal in an effect, so even when nothing actually changed the ref
was new — the hook's useEffect saw a new query and re-fetched.
Wrapped setQuery with a shallow value compare so identical params
are a no-op.
2. The picker /inbounds/options fetch was bundled into refresh() with a
length==0 guard, but the two back-to-back refreshes both saw an
empty inbounds array (the first hadn't resolved yet) so both fired
the request. Moved the options fetch into its own one-shot effect.
* perf(inbounds): share nodes list with form modal instead of refetching
InboundsPage and InboundFormModal both called useNodes() — each
instance maintains its own state and fires its own /panel/api/nodes/list
fetch on mount. Since the modal is always rendered (open or not), every
page load hit the endpoint twice.
Threaded nodes from the page through an availableNodes prop on the form
modal so they share one fetch.
* docs(api): register /clients/list/paged endpoint
TestAPIRoutesDocumented was failing because the new paginated clients
endpoint added in this branch wasn't listed in endpoints.js.
2026-05-23 15:43:43 +00:00
availableNodes? : NodeRecord [ ] ;
Frontend rewrite: React + TypeScript with AntD v6 (#4498)
* chore(frontend): add react+typescript toolchain alongside vue
Step 0 of the planned vue->react migration. React 19, antd 5, i18next
+ react-i18next, typescript 5, and @vitejs/plugin-react 6 are added as
dev/runtime deps alongside the existing vue stack. Both frameworks
coexist in the build until the last entry flips.
* vite.config.js: react() plugin runs next to vue(); new manualChunks
for vendor-react / vendor-antd-react / vendor-icons-react /
vendor-i18next. Existing vue chunks unchanged.
* eslint.config.js: typescript-eslint + eslint-plugin-react-hooks
rules scoped to *.{ts,tsx}; vue config untouched for *.{js,vue}.
* tsconfig.json: strict, jsx: react-jsx, moduleResolution: bundler,
allowJs: true (lets .tsx files import the remaining .js modules
during incremental migration), @/* path alias.
* env.d.ts: Vite client types + window.X_UI_BASE_PATH typing +
SubPageData shape consumed by the subscription page.
Vite stays pinned at 8.0.13 per the existing project policy. No
existing .vue/.js source files touched in this step.
eslint-plugin-react (not -hooks) is not included because its latest
release does not yet support ESLint 10. react-hooks/purity covers
the safety-critical case; revisit when the plugin updates.
* refactor(frontend): port subpage to react+ts
Step 1 of the planned vue->react migration. The standalone
subscription page (sub/sub.go renders the HTML host; React mounts
into #app) is the first entry off vue.
Introduces two shared pieces both entries (and future ones) will
use:
* src/hooks/useTheme.tsx — React Context + useTheme hook + the
same buildAntdThemeConfig (dark/ultra-dark token overrides) and
pauseAnimationsUntilLeave helper the vue version exposes. Same
localStorage keys (dark-mode, isUltraDarkThemeEnabled) and DOM
side effects (body.className, html[data-theme]) so the two stay
in sync across the coexistence period.
* src/i18n/react.ts — i18next + react-i18next loader that reads
the same web/translation/*.json files via import.meta.glob. The
vue-i18n setup in src/i18n/index.js is untouched and still serves
the remaining vue entries.
SubPage.tsx mirrors the vue version's behavior: reads
window.__SUB_PAGE_DATA__ injected by the Go sub server, renders QR
codes / descriptions / Android+iOS deep-link dropdowns, supports
theme cycle and language switch. Uses AntD v5 idioms: Descriptions
items prop, Dropdown menu prop, Layout.Content.
* refactor(frontend): port login to react+ts
Step 2 of the planned vue->react migration. The login entry is the
first to exercise AntD React's Form API (Form + Form.Item with
name/rules + onFinish) and the existing axios/CSRF interceptors
under React.
* LoginPage.tsx: same form fields, conditional 2FA input,
rotating headline ("Hello" / "Welcome to..."), drifting blob
background, theme cycle + language popover. Headline transition
switches from vue's <Transition mode=out-in> to a CSS keyframe
animation keyed off the visible word.
* entries/login.tsx: setupAxios() + applyDocumentTitle() unchanged
from the vue entry — both are framework-agnostic in src/utils
and src/api/axios-init.js.
useTheme hook, ThemeProvider, and i18n/react.ts loader introduced
in step 1 are now shared across two entries; Vite extracts them as
a small chunk in the build output.
* refactor(frontend): port api-docs to react+ts
Step 3 of the planned vue->react migration. The five api-docs files
(ApiDocsPage, CodeBlock, EndpointRow, EndpointSection, plus the
data-only endpoints.js) all move to react+ts.
Also introduces components/AppSidebar.tsx — api-docs is the first
authenticated page to need it. AppSidebar.vue stays in place for the
six remaining vue entries (settings, inbounds, clients, xray, nodes,
index); each gets switched to AppSidebar.tsx as its entry migrates.
After the last entry flips, AppSidebar.vue is deleted.
Notable transformations:
* The scroll observer that highlights the active TOC link is a
useEffect keyed on sections — re-registers whenever the visible
set changes (search filter narrows it). Same behaviour as the vue
watchEffect.
* v-html="safeInlineHtml(...)" becomes
dangerouslySetInnerHTML={{ __html: safeInlineHtml(...) }}. The
helper still escapes everything except <code> tags.
* JSON syntax highlighter in CodeBlock is unchanged — pure regex on
the escaped string, then rendered via dangerouslySetInnerHTML.
* endpoints.js stays as JS (allowJs in tsconfig); only the consumer
signatures (Endpoint, Section) are typed at the React boundary.
* AppSidebar reuses pauseAnimationsUntilLeave + useTheme from
step 1. Drawer + Sider keyed off the same localStorage flag
(isSidebarCollapsed) and DOM theme attributes the vue version
uses, so the two stay in sync during coexistence.
* refactor(frontend): port nodes to react+ts
Step 4 of the planned vue->react migration. The nodes entry brings in
the largest shared-infrastructure batch so far — every authenticated
react page from here on can lean on these.
New shared pieces (live alongside their .vue counterparts during
coexistence):
* hooks/useMediaQuery.ts — useState + resize listener
* hooks/useWebSocket.ts — wraps WebSocketClient, subscribes on mount
and unsubscribes on unmount. The underlying client is a single
module-level instance so multiple components on the same page
share one socket.
* hooks/useNodes.ts — node list state + CRUD + probe/test, including
the totals memo (online/offline/avgLatency) used by the summary card.
applyNodesEvent is the entry point for the heartbeat-pushed list.
* components/CustomStatistic.tsx — thin Statistic wrapper, prefix +
suffix slots become props.
* components/Sparkline.tsx — the SVG line chart with measured-width
axis scaling, gradient fill, tooltip overlay, and per-instance
gradient id from React.useId. ResizeObserver lifecycle is in
useEffect; the math is unchanged.
Pages:
* NodesPage — wires hooks + WebSocket together, renders summary card
+ NodeList, hosts the form modal. Uses Modal.useModal() for the
delete confirm so the dialog inherits ConfigProvider theming.
* NodeList — desktop renders a Table with expandable history rows;
mobile flips to a vertical card list whose actions live in a
bottom-right Dropdown. The IP-blur eye toggle persists across both.
* NodeFormModal — controlled form (useState object, single setForm
per change). The reset-on-open effect computes the next state
once and applies it with eslint-disable to satisfy the new
react-hooks/set-state-in-effect rule on a legitimate pattern.
* NodeHistoryPanel — polls /panel/api/nodes/history/{id}/{metric}/
{bucket} every 15s, renders cpu+mem sparklines side-by-side.
* refactor(frontend): port settings to react+ts
Step 5 of the planned vue->react migration. Settings is the first
entry whose state model didn't translate to the Vue-style "parent
passes a reactive object, children mutate it in place" pattern, so
the React port flips it to lifted state + a typed updateSetting
patch function.
* models/setting.ts — typed AllSetting class with the same field
defaults and equals() behavior the vue version had. The .js
twin is deleted; nothing else imported it.
* hooks/useAllSetting.ts — owns allSetting + oldAllSetting state,
exposes updateSetting(patch), saveDisabled is derived via useMemo
off equals() (no more 1Hz dirty-check timer).
* components/SettingListItem.tsx — children-based wrapper instead
of named slots. The vue twin stays alive because xray (BasicsTab,
DnsTab) still imports it; deleted when xray migrates.
The five tab components and the TwoFactorModal each accept
{ allSetting, updateSetting } and render with AntD v5's Collapse
items[] API. Every v-model:value="x" became
value={...} onChange={(e) => updateSetting({ key: e.target.value })}
or onChange={(v) => updateSetting({ key: v })} for non-input
controls.
SubscriptionFormatsTab is the trickiest — fragment / noises[] /
mux / direct routing rules are stored as JSON-encoded strings on
the wire. Parsing them once via useMemo per field, mutating the
parsed object on edit, and stringifying back into the patch keeps
the round-trip identical to the vue version.
SettingsPage hosts the tab navigation (with hash sync), the
save / restart action bar, the security-warnings alert banner,
and the restart flow that rebuilds the panel URL after the new
host/port/cert settings take effect.
* refactor(frontend): port clients to react+ts
Step 6 of the planned vue->react migration. Clients is the biggest
data-CRUD page in the panel (1.1k-line ClientsPage, 4 modals, full
table + mobile card list, WebSocket-driven realtime traffic + online
updates).
New shared infra (lives alongside vue twins until inbounds migrates):
* hooks/useClients.ts — clients + inbounds list, CRUD + bulk delete +
attach/detach + traffic reset, with WebSocket event handlers
(traffic, client_stats, invalidate) and a small debounced refresh
on the invalidate event. State managed via setState; the live
client_stats event merges traffic snapshots row-by-row through a
ref to avoid stale closure issues.
* hooks/useDatepicker.ts — singleton "gregorian"/"jalalian" cache
with subscribe/notify so multiple components can read the panel's
Calendar Type without re-fetching. Mirrors useDatepicker.js.
* components/DateTimePicker.tsx — AntD DatePicker wrapper.
vue3-persian-datetime-picker has no React port; the Jalali UI
calendar is deferred (read-only Jalali display via IntlUtil
formatDate still works). The vue twin stays for inbounds.
* pages/inbounds/QrPanel.tsx — copy/download/copy-as-png QR helper
shared between clients (qr modal) and inbounds (still on vue).
Vue twin stays alive at QrPanel.vue.
* models/inbound.ts — slim port: only the TLS_FLOW_CONTROL constant
the clients form needs. The full inbound model stays as
inbound.js for now; inbounds will pull it in as inbound.ts.
The clients page itself uses Modal.useModal() for all confirm
dialogs (delete, bulk-delete, reset-traffic, delDepleted, reset-all)
so the dialogs render themed. Filter state persists to
localStorage under clientsFilterState. Sort + pagination state is
local; pageSize seeds from /panel/setting/defaultSettings.
The four modals share a controlled "open/onOpenChange" pattern
that replaces vue's v-model:open. ClientFormModal computes
attach/detach diffs from the inbound multi-select on submit; the
parent's onSave callback routes them through useClients's attach()/
detach() after the main update succeeds.
ESLint config: turned off four react-hooks v7 rules
(react-compiler, preserve-manual-memoization, set-state-in-effect,
purity). They're all React-Compiler-driven informational rules; we
don't run the compiler and the patterns they flag (initial-fetch
useEffect, derived computations using Date.now, inline arrow event
handlers) are all idiomatic React. Disabling globally instead of
per-line keeps the diff readable.
* refactor(frontend): port index dashboard to react+ts
Step 7 of the Vue→React migration. Ports the overview/index entry: dashboard
page, status + xray cards, panel-update / log / backup / system-history /
xray-metrics / xray-log / version modals, and the custom-geo subsection. Adds
the shared JsonEditor (CodeMirror 6) and useStatus hook used by the config
modal. Removes the unused react-hooks/set-state-in-effect disables now that
the rule is off globally.
* refactor(frontend): port xray to react+ts
Step 8 of the Vue→React migration. Ports the xray config entry: page shell,
basics/routing/outbounds/balancers/dns tabs, the rule + balancer + dns server
+ dns presets + warp + nord modals, the protocol-aware outbound form, and the
shared FinalMaskForm (TCP/UDP masks + QUIC params). Adds useXraySetting that
mirrors the legacy two-way sync between the JSON template string and the
parsed templateSettings tree. The outbound model itself stays in JS so the
class-driven form keeps its existing mutation API; instance access is typed
loosely inside the form to match.
The shared FinalMaskForm.vue and JsonEditor.vue stay alongside the new .tsx
versions until step 9 — InboundFormModal.vue still imports them.
Adds react-hooks/immutability and react-hooks/refs to the already-disabled
react-compiler rule set; both flag the outbound form's instance-mutation
pattern that doesn't run through useState.
* Upgrade frontend deps (antd v6, i18n, TS)
Bump frontend dependencies in package.json and regenerate package-lock.json. Notable updates: upgrade antd to v6, update i18next/react-i18next, axios, qs, vue-i18n, TypeScript and ESLint, plus related @rc-component packages and replacements (e.g. classnames/rc-util -> clsx/@rc-component/util). Lockfile changes reflect the new dependency tree required for Ant Design v6 and other package upgrades.
* refactor(frontend): port inbounds to react+ts and drop vue toolchain
Step 9 — the last entry. Ports the inbounds entry: page shell, list with
desktop table + mobile cards, info modal, qr-code modal, share-link
helpers, and the protocol-aware form modal (basics / protocol /
stream / security / sniffing / advanced JSON). useInbounds replaces
the Vue composable with WebSocket-driven traffic + client-stats merge.
Inbound and DBInbound models stay in JS so the class-driven form keeps
its mutation API; instance access is typed loosely inside the form to
match. FinalMaskForm/JsonEditor/TextModal/PromptModal/InfinityIcon are
the last shared bits to flip; their .vue counterparts go too.
Toolchain cleanup now that no entry needs Vue: drop plugin-vue from
vite.config, remove the .vue lint block + parser, prune vue / vue-i18n
/ ant-design-vue / @ant-design/icons-vue / vue3-persian-datetime-picker
/ moment-jalaali override from package.json, and switch utils/index.js
to import { message } from 'antd' instead of ant-design-vue.
* chore(frontend): adopt antd v6 api updates
Sweep deprecated props across the React tree:
- Modal: destroyOnClose -> destroyOnHidden, maskClosable -> mask.closable
- Space: direction -> orientation (or removed when redundant)
- Input.Group compact -> Space.Compact block
- Drawer: width -> size
- Spin: tip -> description
- Progress: trailColor -> railColor
- Alert: message -> title
- Popover: overlayClassName -> rootClassName
- BackTop -> FloatButton.BackTop
Also refresh dashboard theming for v6: rename dark/ultra Layout and Menu
tokens (siderBg, darkItemBg, darkSubMenuItemBg, darkPopupBg), tweak gauge
size/stroke, add font-size overrides for Statistic and Progress so the
overview numbers stay legible under v6 defaults.
* chore(frontend): antd v6 polish, theme + modal fixes
- adopt message.useMessage hook + messageBus bridge so HttpUtil messages
inherit ConfigProvider theme tokens
- replace deprecated antd APIs (List, Input addonBefore/After, Empty
imageStyle); introduce InputAddon helper + SettingListItem custom rows
- fix dark/ultra selectors in portaled modals (body.dark,
html[data-theme='ultra-dark']) instead of nonexistent .is-dark/.is-ultra
- add horizontal scroll to clients table; reorder node columns so
actions+enable sit at the left
- swap raw button for antd Button in NodeFormModal test connection
- fix FinalMaskForm nested-form by hoisting it outside OutboundFormModal's
parent Form
- fix advanced "all" JSON tab in InboundFormModal — useMemo on a mutated
ref was stale; compute on every render
- fix chart-on-open for SystemHistory + XrayMetrics modals by adding open
to effect deps (useRef.current doesn't trigger re-runs)
- switch i18next interpolation to single-brace {var} to match locale files
- drop residual Vue mentions in CI workflows and Go comments
* fix(frontend): qr code collapse — open only first panel, allow toggle
ClientQrModal and QrCodeModal both used activeKey without onChange,
forcing every panel open and blocking user toggle. Switch to controlled
state initialized to the first item's key on open, with onChange so
clicks update state.
Also remove unused AppBridge.tsx (superseded by per-page message.useMessage
hook).
* fix(frontend): hover cards, balancer load, routing dnd, modal a11y, outbound crash
- ClientsPage/SettingsPage/XrayPage: add hoverable to bottom card/tabs so
hover affordance matches the top card
- BalancerFormModal: lazy-init useState from props + destroyOnHidden so
the form mounts with saved values instead of relying on a useEffect
sync that could miss the first open
- RoutingTab: rewrite pointer drag — handlers are now defined inside the
pointerdown closure so addEventListener/removeEventListener match;
drag state lives on a ref (from/to/moved) so onUp reads the real
indices, not stale closure values. Adds setPointerCapture so Windows
and touch keep delivering events when the cursor leaves the handle.
- OutboundFormModal/InboundFormModal: blur the focused input before
switching tabs to silence the aria-hidden-on-focused-element warning
- utils.isArrEmpty: return true for undefined/null arrays — the old form
treated undefined as "not empty" which crashed VLESSSettings.fromJson
when json.vnext was missing
* fix(frontend): clipboard reliability + restyle login page
- ClipboardManager.copyText: prefer navigator.clipboard on secure
contexts, fall back to a focused on-screen textarea + execCommand.
Old path used left:-9999px which failed selection in some browsers
and swallowed execCommand's return value, so the "copied" toast
appeared even when nothing made it to the clipboard.
- LoginPage: richer gradient backdrop — five animated colour blobs,
glassmorphic card (backdrop-filter blur + saturate), gradient brand
text/accent, masked grid texture for depth, and a thin gradient
border on the card. Light/dark/ultra each get their own palette.
* Memoize compactAdvancedJson and update deps
Wrap compactAdvancedJson in useCallback (dependent on messageApi) and add it to the dependency array of applyAdvancedJsonToBasic. This ensures a stable function reference for correct dependency tracking and avoids stale closures/unnecessary re-renders in InboundFormModal.tsx.
* style(frontend): prettier charts, drop redundant frame, format net rates
- Sparkline: multi-stop gradient fill, soft drop-shadow under the line,
dashed grid, glowing pulse on the latest-point marker, pill-shaped
tooltip with dashed crosshair
- XrayMetricsModal: glow + pulse on the observatory alive dot,
monospace stamps/listen text
- SystemHistoryModal: keep just the modal's frame around the chart (the
inner wrapper I'd added stacked a second border on top); strip the
decimal from Net Up/Down (25.63 KB/s → 25 KB/s) only on this chart's
formatter
* style(frontend): refined dark/ultra palette + shared pro card frame
- Dark tokens shifted to a cooler, Linear-style palette: page #1a1b1f,
sidebar/header #15161a (recessed nav, darker than cards), card
#23252b, elevated #2d2f37
- Ultra dark: page pure #000 for OLED, sidebar #050507 disappears into
the frame, card #101013 with a clear step, elevated #1a1a1e
- New styles/page-cards.css holds the card border/shadow/hover rules so
all seven content pages (index, clients, inbounds, xray, settings,
nodes, api-docs) share one definition instead of duplicating in each
page CSS
- Dashboard typography: uppercase card titles with letter-spacing,
larger 17px stat values, subtle gradient divider between stat columns,
ellipsis on action labels so "Backup & Restore" doesn't break the
card height at mid widths
- Light --bg-page stays at #e6e8ec for the contrast against white cards
* fix(frontend): wireguard info alignment, blue login dark, embed gitkeep
- align WireGuard info-modal fields with Protocol/Address/Port by wrapping
values in Tag (matches the rest of the dl.info-list rows)
- swap login dark palette from purple to pure blue blobs/accent/brand
- pin web/dist/.gitkeep through gitignore so //go:embed all:dist never
fails on a fresh clone with an empty dist directory
* docs: refresh frontend docs for the React + TS + AntD 6 stack
Update CONTRIBUTING.md and frontend/README.md to describe the migrated
frontend accurately:
- replace Vue 3 / Ant Design Vue 4 references with React 19 / AntD 6 / TS
- swap composables -> hooks, vue-i18n -> react-i18next, createApp -> createRoot
- mention the typecheck step (tsc --noEmit) in the PR checklist
- document the Vite 8.0.13 pin and TypeScript strict mode in conventions
- list the nodes and api-docs entries that were missing from the layout
* style(frontend): improve readability and mobile polish
- bump statistic title/value contrast in dark and ultra-dark so totals
on the inbounds summary card stay legible
- give index card actions explicit colors per theme so links like Stop,
Logs, System History no longer fade into the card background
- show the panel version as a tag next to "3X-UI" on mobile, mirroring
the Xray version tag pattern, and turn it orange when an update is
available
- make the login settings button a proper circle by adding size="large"
+ an explicit border-radius fallback on .toolbar-btn
* feat: jalali calendar support and date formatting fixes
- Wire useDatepicker into IntlUtil and switch jalalian display locale
to fa-IR for clean "1405/07/03 12:00:00" output (drops the awkward
"AP" era suffix that "<lang>-u-ca-persian" produced)
- Drop in persian-calendar-suite for the jalali date picker, with a
light/dark/ultra theme map and CSS overrides so the inline-styled
input stays readable and bg matches the surrounding container
- Force LTR on the picker input so "1405/03/07 00:00" reads naturally
- Pass calendar setting through ClientInfoModal, ClientsPage Duration
tooltip, and ClientFormModal's expiry picker
- Heuristic toMs() in ClientInfoModal so GORM's autoUpdateTime seconds
render as a real date instead of "1348/11/01"
- Persist UpdatedAt on the ClientRecord row in client_service.Update;
previously only the inbound settings JSON was bumped, so the panel
never saw a fresh updated_at after editing a client
* feat(frontend): donate link, panel version label, login lang menu
- Sidebar: add heart donate link to https://donate.sanaei.dev and small panel version under 3X-UI brand
- Login: swap settings-cog for translation icon, drop title, render languages as a direct list
- Vite dev: inject window.X_UI_CUR_VER from config/version so dev mode matches prod
- Translations: add menu.donate across all locales
* fix(xray-update): respect XUI_BIN_FOLDER on Windows
The Windows update path hardcoded "bin/xray-windows-amd64.exe", ignoring
the configured XUI_BIN_FOLDER. In dev mode (folder set to x-ui) this
created a stray bin/ folder while the running binary stayed un-updated.
* Bump Xray to v26.5.9 and minor cleanup
Update Xray release URLs to v26.5.9 in the GitHub Actions workflow and DockerInit.sh. Remove the hardcoded skip for tagVersion "26.5.3" so it will be considered when collecting Xray versions. Apply small formatting fixes: remove an extra blank line in database/db.go, normalize spacing/alignment of Protocol constants in database/model/model.go, and trim a trailing blank line in web/controller/inbound.go.
* fix(frontend): route remaining copy buttons through ClipboardManager
Direct navigator.clipboard calls fail in non-secure contexts (HTTP on a
LAN IP), making the API-docs code copy and security-tab token copy
silently broken. Both now go through ClipboardManager which falls back
to document.execCommand('copy') when navigator.clipboard is unavailable.
* fix(db): store CreatedAt/UpdatedAt in milliseconds
GORM's autoCreateTime/autoUpdateTime tags default to Unix seconds on
int64 fields and overwrite the service-supplied UnixMilli value on
save. The frontend interprets these timestamps as JS Date inputs
(milliseconds), so created/updated columns rendered ~1970 dates. Adding
the :milli qualifier makes GORM match what the service code and UI
expect.
* Improve legacy clipboard copy handling
Refactor ClipboardManager._legacyCopy to better handle focus and selection when copying. The textarea is now appended to the active element's parent (or body) and placed off-screen with aria-hidden and readonly attributes. The code preserves and restores the previous document selection and active element, uses focus({preventScroll: true}) to avoid scrolling, and returns the execCommand('copy') result. This makes legacy copy behavior more robust and less disruptive to the page state.
* fix(lint): drop redundant ok=false in clipboard fallback catch
* chore(deps): bump golang.org/x/net to v0.55.0 for GO-2026-5026
2026-05-23 13:21:45 +00:00
}
const TRAFFIC_RESETS = [ 'never' , 'hourly' , 'daily' , 'weekly' , 'monthly' ] ;
const PROTOCOLS = Object . values ( Protocols ) as string [ ] ;
const TLS_VERSIONS = Object . values ( TLS_VERSION_OPTION ) as string [ ] ;
const CIPHER_SUITES = Object . entries ( TLS_CIPHER_OPTION ) as [ string , string ] [ ] ;
const FINGERPRINTS = Object . values ( UTLS_FINGERPRINT ) as string [ ] ;
const ALPNS = Object . values ( ALPN_OPTION ) as string [ ] ;
const USAGES = Object . values ( USAGE_OPTION ) as string [ ] ;
const DOMAIN_STRATEGIES = Object . values ( DOMAIN_STRATEGY_OPTION ) as string [ ] ;
const TCP_CONGESTIONS = Object . values ( TCP_CONGESTION_OPTION ) as string [ ] ;
const MODE_OPTIONS = Object . values ( MODE_OPTION ) as string [ ] ;
const NODE_ELIGIBLE_PROTOCOLS = new Set ( [
Protocols . VLESS ,
Protocols . VMESS ,
Protocols . TROJAN ,
Protocols . SHADOWSOCKS ,
Protocols . HYSTERIA ,
Protocols . WIREGUARD ,
] ) ;
const FALLBACK_ELIGIBLE_TRANSPORTS = new Set ( [ 'tcp' , 'ws' , 'grpc' , 'httpupgrade' , 'xhttp' ] ) ;
interface FallbackRow {
rowKey : string ;
childId : number | null ;
name : string ;
alpn : string ;
path : string ;
xver : number ;
}
function deriveFallbackDefaults ( childDb : any ) : Omit < FallbackRow , ' rowKey ' | ' childId ' > {
const out = { name : '' , alpn : '' , path : '' , xver : 0 } ;
if ( ! childDb ) return out ;
let stream : any ;
try {
stream = childDb . toInbound ( ) ? . stream ;
} catch {
return out ;
}
if ( ! stream ) return out ;
switch ( stream . network ) {
case 'tcp' : {
const tcp = stream . tcp ;
if ( tcp ? . type === 'http' ) {
const p = tcp ? . request ? . path ;
if ( Array . isArray ( p ) && p . length ) out . path = p [ 0 ] ;
}
if ( tcp ? . acceptProxyProtocol ) out . xver = 2 ;
break ;
}
case 'ws' : {
out . path = stream . ws ? . path || '' ;
if ( stream . ws ? . acceptProxyProtocol ) out . xver = 2 ;
break ;
}
case 'grpc' : {
out . path = stream . grpc ? . serviceName || '' ;
out . alpn = 'h2' ;
break ;
}
case 'httpupgrade' : {
out . path = stream . httpupgrade ? . path || '' ;
if ( stream . httpupgrade ? . acceptProxyProtocol ) out . xver = 2 ;
break ;
}
case 'xhttp' : {
out . path = stream . xhttp ? . path || '' ;
break ;
}
}
return out ;
}
export default function InboundFormModal ( {
open ,
onClose ,
onSaved ,
mode ,
dbInbound ,
dbInbounds ,
Reduce list-page payloads with slim/paged endpoints (#4500)
* perf(inbounds): slim list payload + lazy hydrate for row actions
Adds GET /panel/api/inbounds/list/slim that returns the same list shape
but strips every per-client field besides email/enable/comment from
settings.clients[] and skips UUID/SubId enrichment on ClientStats.
The inbounds page only reads those three to compute its client counters
and badges, so the slim variant trims tens of bytes per client (uuid,
password, flow, security, totalGB, expiryTime, limitIp, tgId, ...).
On a panel with thousands of clients this is the dominant load-time
cost.
Detail flows (edit / info / qr / export / clone) call /get/:id through
a new hydrateInbound helper before opening — the slim list view never
needs the secrets it doesn't render.
* perf(clients): server-side pagination + slim row payload
Adds GET /panel/api/clients/list/paged that filters, sorts, and paginates
on the server, returns a slim row shape (drops uuid/password/auth/flow/
security/reverse/tgId per client), and includes a stable summary
(total, active, online[], depleted[], expiring[], deactive[]) computed
across the full DB row set so the dashboard cards don't change as the
user paginates or filters. Page size capped at 200.
useClients now exposes { clients (current page), total, filtered, query,
setQuery, summary, hydrate }. ClientsPage feeds its filter/sort/page
state into setQuery via a single effect, debounces search by 300ms, and
hydrates the full client record via /get/:email before opening edit/info/
qr modals. Local filter/sort logic and the all-clients summary memo are
gone.
On a 2000-client panel this turns the initial response from ~MB to ~25 row
slice (~10s of KB) and removes the all-client parse cost from every
refresh.
* perf(settings): use /inbounds/options for LDAP tag picker
The General settings tab only needs each inbound's tag/protocol/port to
fill a dropdown but was calling /panel/api/inbounds/list which ships the
full settings JSON with every embedded client. Switched it to /options
and added Tag to the projection. On a panel with thousands of clients
this drops the General-tab load payload from megabytes to a tiny
per-inbound row each.
* perf(clients): de-duplicate options + paged list fetches
Two issues caused each clients-page load to fire its requests twice:
1. setQuery in the hook took whatever object the consumer passed and
stored it as-is. The consumer (ClientsPage) constructs a new object
literal in an effect, so even when nothing actually changed the ref
was new — the hook's useEffect saw a new query and re-fetched.
Wrapped setQuery with a shallow value compare so identical params
are a no-op.
2. The picker /inbounds/options fetch was bundled into refresh() with a
length==0 guard, but the two back-to-back refreshes both saw an
empty inbounds array (the first hadn't resolved yet) so both fired
the request. Moved the options fetch into its own one-shot effect.
* perf(inbounds): share nodes list with form modal instead of refetching
InboundsPage and InboundFormModal both called useNodes() — each
instance maintains its own state and fires its own /panel/api/nodes/list
fetch on mount. Since the modal is always rendered (open or not), every
page load hit the endpoint twice.
Threaded nodes from the page through an availableNodes prop on the form
modal so they share one fetch.
* docs(api): register /clients/list/paged endpoint
TestAPIRoutesDocumented was failing because the new paginated clients
endpoint added in this branch wasn't listed in endpoints.js.
2026-05-23 15:43:43 +00:00
availableNodes ,
Frontend rewrite: React + TypeScript with AntD v6 (#4498)
* chore(frontend): add react+typescript toolchain alongside vue
Step 0 of the planned vue->react migration. React 19, antd 5, i18next
+ react-i18next, typescript 5, and @vitejs/plugin-react 6 are added as
dev/runtime deps alongside the existing vue stack. Both frameworks
coexist in the build until the last entry flips.
* vite.config.js: react() plugin runs next to vue(); new manualChunks
for vendor-react / vendor-antd-react / vendor-icons-react /
vendor-i18next. Existing vue chunks unchanged.
* eslint.config.js: typescript-eslint + eslint-plugin-react-hooks
rules scoped to *.{ts,tsx}; vue config untouched for *.{js,vue}.
* tsconfig.json: strict, jsx: react-jsx, moduleResolution: bundler,
allowJs: true (lets .tsx files import the remaining .js modules
during incremental migration), @/* path alias.
* env.d.ts: Vite client types + window.X_UI_BASE_PATH typing +
SubPageData shape consumed by the subscription page.
Vite stays pinned at 8.0.13 per the existing project policy. No
existing .vue/.js source files touched in this step.
eslint-plugin-react (not -hooks) is not included because its latest
release does not yet support ESLint 10. react-hooks/purity covers
the safety-critical case; revisit when the plugin updates.
* refactor(frontend): port subpage to react+ts
Step 1 of the planned vue->react migration. The standalone
subscription page (sub/sub.go renders the HTML host; React mounts
into #app) is the first entry off vue.
Introduces two shared pieces both entries (and future ones) will
use:
* src/hooks/useTheme.tsx — React Context + useTheme hook + the
same buildAntdThemeConfig (dark/ultra-dark token overrides) and
pauseAnimationsUntilLeave helper the vue version exposes. Same
localStorage keys (dark-mode, isUltraDarkThemeEnabled) and DOM
side effects (body.className, html[data-theme]) so the two stay
in sync across the coexistence period.
* src/i18n/react.ts — i18next + react-i18next loader that reads
the same web/translation/*.json files via import.meta.glob. The
vue-i18n setup in src/i18n/index.js is untouched and still serves
the remaining vue entries.
SubPage.tsx mirrors the vue version's behavior: reads
window.__SUB_PAGE_DATA__ injected by the Go sub server, renders QR
codes / descriptions / Android+iOS deep-link dropdowns, supports
theme cycle and language switch. Uses AntD v5 idioms: Descriptions
items prop, Dropdown menu prop, Layout.Content.
* refactor(frontend): port login to react+ts
Step 2 of the planned vue->react migration. The login entry is the
first to exercise AntD React's Form API (Form + Form.Item with
name/rules + onFinish) and the existing axios/CSRF interceptors
under React.
* LoginPage.tsx: same form fields, conditional 2FA input,
rotating headline ("Hello" / "Welcome to..."), drifting blob
background, theme cycle + language popover. Headline transition
switches from vue's <Transition mode=out-in> to a CSS keyframe
animation keyed off the visible word.
* entries/login.tsx: setupAxios() + applyDocumentTitle() unchanged
from the vue entry — both are framework-agnostic in src/utils
and src/api/axios-init.js.
useTheme hook, ThemeProvider, and i18n/react.ts loader introduced
in step 1 are now shared across two entries; Vite extracts them as
a small chunk in the build output.
* refactor(frontend): port api-docs to react+ts
Step 3 of the planned vue->react migration. The five api-docs files
(ApiDocsPage, CodeBlock, EndpointRow, EndpointSection, plus the
data-only endpoints.js) all move to react+ts.
Also introduces components/AppSidebar.tsx — api-docs is the first
authenticated page to need it. AppSidebar.vue stays in place for the
six remaining vue entries (settings, inbounds, clients, xray, nodes,
index); each gets switched to AppSidebar.tsx as its entry migrates.
After the last entry flips, AppSidebar.vue is deleted.
Notable transformations:
* The scroll observer that highlights the active TOC link is a
useEffect keyed on sections — re-registers whenever the visible
set changes (search filter narrows it). Same behaviour as the vue
watchEffect.
* v-html="safeInlineHtml(...)" becomes
dangerouslySetInnerHTML={{ __html: safeInlineHtml(...) }}. The
helper still escapes everything except <code> tags.
* JSON syntax highlighter in CodeBlock is unchanged — pure regex on
the escaped string, then rendered via dangerouslySetInnerHTML.
* endpoints.js stays as JS (allowJs in tsconfig); only the consumer
signatures (Endpoint, Section) are typed at the React boundary.
* AppSidebar reuses pauseAnimationsUntilLeave + useTheme from
step 1. Drawer + Sider keyed off the same localStorage flag
(isSidebarCollapsed) and DOM theme attributes the vue version
uses, so the two stay in sync during coexistence.
* refactor(frontend): port nodes to react+ts
Step 4 of the planned vue->react migration. The nodes entry brings in
the largest shared-infrastructure batch so far — every authenticated
react page from here on can lean on these.
New shared pieces (live alongside their .vue counterparts during
coexistence):
* hooks/useMediaQuery.ts — useState + resize listener
* hooks/useWebSocket.ts — wraps WebSocketClient, subscribes on mount
and unsubscribes on unmount. The underlying client is a single
module-level instance so multiple components on the same page
share one socket.
* hooks/useNodes.ts — node list state + CRUD + probe/test, including
the totals memo (online/offline/avgLatency) used by the summary card.
applyNodesEvent is the entry point for the heartbeat-pushed list.
* components/CustomStatistic.tsx — thin Statistic wrapper, prefix +
suffix slots become props.
* components/Sparkline.tsx — the SVG line chart with measured-width
axis scaling, gradient fill, tooltip overlay, and per-instance
gradient id from React.useId. ResizeObserver lifecycle is in
useEffect; the math is unchanged.
Pages:
* NodesPage — wires hooks + WebSocket together, renders summary card
+ NodeList, hosts the form modal. Uses Modal.useModal() for the
delete confirm so the dialog inherits ConfigProvider theming.
* NodeList — desktop renders a Table with expandable history rows;
mobile flips to a vertical card list whose actions live in a
bottom-right Dropdown. The IP-blur eye toggle persists across both.
* NodeFormModal — controlled form (useState object, single setForm
per change). The reset-on-open effect computes the next state
once and applies it with eslint-disable to satisfy the new
react-hooks/set-state-in-effect rule on a legitimate pattern.
* NodeHistoryPanel — polls /panel/api/nodes/history/{id}/{metric}/
{bucket} every 15s, renders cpu+mem sparklines side-by-side.
* refactor(frontend): port settings to react+ts
Step 5 of the planned vue->react migration. Settings is the first
entry whose state model didn't translate to the Vue-style "parent
passes a reactive object, children mutate it in place" pattern, so
the React port flips it to lifted state + a typed updateSetting
patch function.
* models/setting.ts — typed AllSetting class with the same field
defaults and equals() behavior the vue version had. The .js
twin is deleted; nothing else imported it.
* hooks/useAllSetting.ts — owns allSetting + oldAllSetting state,
exposes updateSetting(patch), saveDisabled is derived via useMemo
off equals() (no more 1Hz dirty-check timer).
* components/SettingListItem.tsx — children-based wrapper instead
of named slots. The vue twin stays alive because xray (BasicsTab,
DnsTab) still imports it; deleted when xray migrates.
The five tab components and the TwoFactorModal each accept
{ allSetting, updateSetting } and render with AntD v5's Collapse
items[] API. Every v-model:value="x" became
value={...} onChange={(e) => updateSetting({ key: e.target.value })}
or onChange={(v) => updateSetting({ key: v })} for non-input
controls.
SubscriptionFormatsTab is the trickiest — fragment / noises[] /
mux / direct routing rules are stored as JSON-encoded strings on
the wire. Parsing them once via useMemo per field, mutating the
parsed object on edit, and stringifying back into the patch keeps
the round-trip identical to the vue version.
SettingsPage hosts the tab navigation (with hash sync), the
save / restart action bar, the security-warnings alert banner,
and the restart flow that rebuilds the panel URL after the new
host/port/cert settings take effect.
* refactor(frontend): port clients to react+ts
Step 6 of the planned vue->react migration. Clients is the biggest
data-CRUD page in the panel (1.1k-line ClientsPage, 4 modals, full
table + mobile card list, WebSocket-driven realtime traffic + online
updates).
New shared infra (lives alongside vue twins until inbounds migrates):
* hooks/useClients.ts — clients + inbounds list, CRUD + bulk delete +
attach/detach + traffic reset, with WebSocket event handlers
(traffic, client_stats, invalidate) and a small debounced refresh
on the invalidate event. State managed via setState; the live
client_stats event merges traffic snapshots row-by-row through a
ref to avoid stale closure issues.
* hooks/useDatepicker.ts — singleton "gregorian"/"jalalian" cache
with subscribe/notify so multiple components can read the panel's
Calendar Type without re-fetching. Mirrors useDatepicker.js.
* components/DateTimePicker.tsx — AntD DatePicker wrapper.
vue3-persian-datetime-picker has no React port; the Jalali UI
calendar is deferred (read-only Jalali display via IntlUtil
formatDate still works). The vue twin stays for inbounds.
* pages/inbounds/QrPanel.tsx — copy/download/copy-as-png QR helper
shared between clients (qr modal) and inbounds (still on vue).
Vue twin stays alive at QrPanel.vue.
* models/inbound.ts — slim port: only the TLS_FLOW_CONTROL constant
the clients form needs. The full inbound model stays as
inbound.js for now; inbounds will pull it in as inbound.ts.
The clients page itself uses Modal.useModal() for all confirm
dialogs (delete, bulk-delete, reset-traffic, delDepleted, reset-all)
so the dialogs render themed. Filter state persists to
localStorage under clientsFilterState. Sort + pagination state is
local; pageSize seeds from /panel/setting/defaultSettings.
The four modals share a controlled "open/onOpenChange" pattern
that replaces vue's v-model:open. ClientFormModal computes
attach/detach diffs from the inbound multi-select on submit; the
parent's onSave callback routes them through useClients's attach()/
detach() after the main update succeeds.
ESLint config: turned off four react-hooks v7 rules
(react-compiler, preserve-manual-memoization, set-state-in-effect,
purity). They're all React-Compiler-driven informational rules; we
don't run the compiler and the patterns they flag (initial-fetch
useEffect, derived computations using Date.now, inline arrow event
handlers) are all idiomatic React. Disabling globally instead of
per-line keeps the diff readable.
* refactor(frontend): port index dashboard to react+ts
Step 7 of the Vue→React migration. Ports the overview/index entry: dashboard
page, status + xray cards, panel-update / log / backup / system-history /
xray-metrics / xray-log / version modals, and the custom-geo subsection. Adds
the shared JsonEditor (CodeMirror 6) and useStatus hook used by the config
modal. Removes the unused react-hooks/set-state-in-effect disables now that
the rule is off globally.
* refactor(frontend): port xray to react+ts
Step 8 of the Vue→React migration. Ports the xray config entry: page shell,
basics/routing/outbounds/balancers/dns tabs, the rule + balancer + dns server
+ dns presets + warp + nord modals, the protocol-aware outbound form, and the
shared FinalMaskForm (TCP/UDP masks + QUIC params). Adds useXraySetting that
mirrors the legacy two-way sync between the JSON template string and the
parsed templateSettings tree. The outbound model itself stays in JS so the
class-driven form keeps its existing mutation API; instance access is typed
loosely inside the form to match.
The shared FinalMaskForm.vue and JsonEditor.vue stay alongside the new .tsx
versions until step 9 — InboundFormModal.vue still imports them.
Adds react-hooks/immutability and react-hooks/refs to the already-disabled
react-compiler rule set; both flag the outbound form's instance-mutation
pattern that doesn't run through useState.
* Upgrade frontend deps (antd v6, i18n, TS)
Bump frontend dependencies in package.json and regenerate package-lock.json. Notable updates: upgrade antd to v6, update i18next/react-i18next, axios, qs, vue-i18n, TypeScript and ESLint, plus related @rc-component packages and replacements (e.g. classnames/rc-util -> clsx/@rc-component/util). Lockfile changes reflect the new dependency tree required for Ant Design v6 and other package upgrades.
* refactor(frontend): port inbounds to react+ts and drop vue toolchain
Step 9 — the last entry. Ports the inbounds entry: page shell, list with
desktop table + mobile cards, info modal, qr-code modal, share-link
helpers, and the protocol-aware form modal (basics / protocol /
stream / security / sniffing / advanced JSON). useInbounds replaces
the Vue composable with WebSocket-driven traffic + client-stats merge.
Inbound and DBInbound models stay in JS so the class-driven form keeps
its mutation API; instance access is typed loosely inside the form to
match. FinalMaskForm/JsonEditor/TextModal/PromptModal/InfinityIcon are
the last shared bits to flip; their .vue counterparts go too.
Toolchain cleanup now that no entry needs Vue: drop plugin-vue from
vite.config, remove the .vue lint block + parser, prune vue / vue-i18n
/ ant-design-vue / @ant-design/icons-vue / vue3-persian-datetime-picker
/ moment-jalaali override from package.json, and switch utils/index.js
to import { message } from 'antd' instead of ant-design-vue.
* chore(frontend): adopt antd v6 api updates
Sweep deprecated props across the React tree:
- Modal: destroyOnClose -> destroyOnHidden, maskClosable -> mask.closable
- Space: direction -> orientation (or removed when redundant)
- Input.Group compact -> Space.Compact block
- Drawer: width -> size
- Spin: tip -> description
- Progress: trailColor -> railColor
- Alert: message -> title
- Popover: overlayClassName -> rootClassName
- BackTop -> FloatButton.BackTop
Also refresh dashboard theming for v6: rename dark/ultra Layout and Menu
tokens (siderBg, darkItemBg, darkSubMenuItemBg, darkPopupBg), tweak gauge
size/stroke, add font-size overrides for Statistic and Progress so the
overview numbers stay legible under v6 defaults.
* chore(frontend): antd v6 polish, theme + modal fixes
- adopt message.useMessage hook + messageBus bridge so HttpUtil messages
inherit ConfigProvider theme tokens
- replace deprecated antd APIs (List, Input addonBefore/After, Empty
imageStyle); introduce InputAddon helper + SettingListItem custom rows
- fix dark/ultra selectors in portaled modals (body.dark,
html[data-theme='ultra-dark']) instead of nonexistent .is-dark/.is-ultra
- add horizontal scroll to clients table; reorder node columns so
actions+enable sit at the left
- swap raw button for antd Button in NodeFormModal test connection
- fix FinalMaskForm nested-form by hoisting it outside OutboundFormModal's
parent Form
- fix advanced "all" JSON tab in InboundFormModal — useMemo on a mutated
ref was stale; compute on every render
- fix chart-on-open for SystemHistory + XrayMetrics modals by adding open
to effect deps (useRef.current doesn't trigger re-runs)
- switch i18next interpolation to single-brace {var} to match locale files
- drop residual Vue mentions in CI workflows and Go comments
* fix(frontend): qr code collapse — open only first panel, allow toggle
ClientQrModal and QrCodeModal both used activeKey without onChange,
forcing every panel open and blocking user toggle. Switch to controlled
state initialized to the first item's key on open, with onChange so
clicks update state.
Also remove unused AppBridge.tsx (superseded by per-page message.useMessage
hook).
* fix(frontend): hover cards, balancer load, routing dnd, modal a11y, outbound crash
- ClientsPage/SettingsPage/XrayPage: add hoverable to bottom card/tabs so
hover affordance matches the top card
- BalancerFormModal: lazy-init useState from props + destroyOnHidden so
the form mounts with saved values instead of relying on a useEffect
sync that could miss the first open
- RoutingTab: rewrite pointer drag — handlers are now defined inside the
pointerdown closure so addEventListener/removeEventListener match;
drag state lives on a ref (from/to/moved) so onUp reads the real
indices, not stale closure values. Adds setPointerCapture so Windows
and touch keep delivering events when the cursor leaves the handle.
- OutboundFormModal/InboundFormModal: blur the focused input before
switching tabs to silence the aria-hidden-on-focused-element warning
- utils.isArrEmpty: return true for undefined/null arrays — the old form
treated undefined as "not empty" which crashed VLESSSettings.fromJson
when json.vnext was missing
* fix(frontend): clipboard reliability + restyle login page
- ClipboardManager.copyText: prefer navigator.clipboard on secure
contexts, fall back to a focused on-screen textarea + execCommand.
Old path used left:-9999px which failed selection in some browsers
and swallowed execCommand's return value, so the "copied" toast
appeared even when nothing made it to the clipboard.
- LoginPage: richer gradient backdrop — five animated colour blobs,
glassmorphic card (backdrop-filter blur + saturate), gradient brand
text/accent, masked grid texture for depth, and a thin gradient
border on the card. Light/dark/ultra each get their own palette.
* Memoize compactAdvancedJson and update deps
Wrap compactAdvancedJson in useCallback (dependent on messageApi) and add it to the dependency array of applyAdvancedJsonToBasic. This ensures a stable function reference for correct dependency tracking and avoids stale closures/unnecessary re-renders in InboundFormModal.tsx.
* style(frontend): prettier charts, drop redundant frame, format net rates
- Sparkline: multi-stop gradient fill, soft drop-shadow under the line,
dashed grid, glowing pulse on the latest-point marker, pill-shaped
tooltip with dashed crosshair
- XrayMetricsModal: glow + pulse on the observatory alive dot,
monospace stamps/listen text
- SystemHistoryModal: keep just the modal's frame around the chart (the
inner wrapper I'd added stacked a second border on top); strip the
decimal from Net Up/Down (25.63 KB/s → 25 KB/s) only on this chart's
formatter
* style(frontend): refined dark/ultra palette + shared pro card frame
- Dark tokens shifted to a cooler, Linear-style palette: page #1a1b1f,
sidebar/header #15161a (recessed nav, darker than cards), card
#23252b, elevated #2d2f37
- Ultra dark: page pure #000 for OLED, sidebar #050507 disappears into
the frame, card #101013 with a clear step, elevated #1a1a1e
- New styles/page-cards.css holds the card border/shadow/hover rules so
all seven content pages (index, clients, inbounds, xray, settings,
nodes, api-docs) share one definition instead of duplicating in each
page CSS
- Dashboard typography: uppercase card titles with letter-spacing,
larger 17px stat values, subtle gradient divider between stat columns,
ellipsis on action labels so "Backup & Restore" doesn't break the
card height at mid widths
- Light --bg-page stays at #e6e8ec for the contrast against white cards
* fix(frontend): wireguard info alignment, blue login dark, embed gitkeep
- align WireGuard info-modal fields with Protocol/Address/Port by wrapping
values in Tag (matches the rest of the dl.info-list rows)
- swap login dark palette from purple to pure blue blobs/accent/brand
- pin web/dist/.gitkeep through gitignore so //go:embed all:dist never
fails on a fresh clone with an empty dist directory
* docs: refresh frontend docs for the React + TS + AntD 6 stack
Update CONTRIBUTING.md and frontend/README.md to describe the migrated
frontend accurately:
- replace Vue 3 / Ant Design Vue 4 references with React 19 / AntD 6 / TS
- swap composables -> hooks, vue-i18n -> react-i18next, createApp -> createRoot
- mention the typecheck step (tsc --noEmit) in the PR checklist
- document the Vite 8.0.13 pin and TypeScript strict mode in conventions
- list the nodes and api-docs entries that were missing from the layout
* style(frontend): improve readability and mobile polish
- bump statistic title/value contrast in dark and ultra-dark so totals
on the inbounds summary card stay legible
- give index card actions explicit colors per theme so links like Stop,
Logs, System History no longer fade into the card background
- show the panel version as a tag next to "3X-UI" on mobile, mirroring
the Xray version tag pattern, and turn it orange when an update is
available
- make the login settings button a proper circle by adding size="large"
+ an explicit border-radius fallback on .toolbar-btn
* feat: jalali calendar support and date formatting fixes
- Wire useDatepicker into IntlUtil and switch jalalian display locale
to fa-IR for clean "1405/07/03 12:00:00" output (drops the awkward
"AP" era suffix that "<lang>-u-ca-persian" produced)
- Drop in persian-calendar-suite for the jalali date picker, with a
light/dark/ultra theme map and CSS overrides so the inline-styled
input stays readable and bg matches the surrounding container
- Force LTR on the picker input so "1405/03/07 00:00" reads naturally
- Pass calendar setting through ClientInfoModal, ClientsPage Duration
tooltip, and ClientFormModal's expiry picker
- Heuristic toMs() in ClientInfoModal so GORM's autoUpdateTime seconds
render as a real date instead of "1348/11/01"
- Persist UpdatedAt on the ClientRecord row in client_service.Update;
previously only the inbound settings JSON was bumped, so the panel
never saw a fresh updated_at after editing a client
* feat(frontend): donate link, panel version label, login lang menu
- Sidebar: add heart donate link to https://donate.sanaei.dev and small panel version under 3X-UI brand
- Login: swap settings-cog for translation icon, drop title, render languages as a direct list
- Vite dev: inject window.X_UI_CUR_VER from config/version so dev mode matches prod
- Translations: add menu.donate across all locales
* fix(xray-update): respect XUI_BIN_FOLDER on Windows
The Windows update path hardcoded "bin/xray-windows-amd64.exe", ignoring
the configured XUI_BIN_FOLDER. In dev mode (folder set to x-ui) this
created a stray bin/ folder while the running binary stayed un-updated.
* Bump Xray to v26.5.9 and minor cleanup
Update Xray release URLs to v26.5.9 in the GitHub Actions workflow and DockerInit.sh. Remove the hardcoded skip for tagVersion "26.5.3" so it will be considered when collecting Xray versions. Apply small formatting fixes: remove an extra blank line in database/db.go, normalize spacing/alignment of Protocol constants in database/model/model.go, and trim a trailing blank line in web/controller/inbound.go.
* fix(frontend): route remaining copy buttons through ClipboardManager
Direct navigator.clipboard calls fail in non-secure contexts (HTTP on a
LAN IP), making the API-docs code copy and security-tab token copy
silently broken. Both now go through ClipboardManager which falls back
to document.execCommand('copy') when navigator.clipboard is unavailable.
* fix(db): store CreatedAt/UpdatedAt in milliseconds
GORM's autoCreateTime/autoUpdateTime tags default to Unix seconds on
int64 fields and overwrite the service-supplied UnixMilli value on
save. The frontend interprets these timestamps as JS Date inputs
(milliseconds), so created/updated columns rendered ~1970 dates. Adding
the :milli qualifier makes GORM match what the service code and UI
expect.
* Improve legacy clipboard copy handling
Refactor ClipboardManager._legacyCopy to better handle focus and selection when copying. The textarea is now appended to the active element's parent (or body) and placed off-screen with aria-hidden and readonly attributes. The code preserves and restores the previous document selection and active element, uses focus({preventScroll: true}) to avoid scrolling, and returns the execCommand('copy') result. This makes legacy copy behavior more robust and less disruptive to the page state.
* fix(lint): drop redundant ok=false in clipboard fallback catch
* chore(deps): bump golang.org/x/net to v0.55.0 for GO-2026-5026
2026-05-23 13:21:45 +00:00
} : InboundFormModalProps ) {
const { t } = useTranslation ( ) ;
const [ messageApi , messageContextHolder ] = message . useMessage ( ) ;
const selectableNodes = useMemo (
( ) = > ( availableNodes || [ ] ) . filter ( ( n : NodeRecord ) = > n . enable ) ,
[ availableNodes ] ,
) ;
const inboundRef = useRef < any > ( null ) ;
const dbFormRef = useRef < any > ( null ) ;
const fallbackKeyRef = useRef ( 0 ) ;
const advancedTextRef = useRef ( { stream : '' , sniffing : '' , settings : '' } ) ;
const [ , setTick ] = useState ( 0 ) ;
const refresh = useCallback ( ( ) = > setTick ( ( n ) = > n + 1 ) , [ ] ) ;
const [ saving , setSaving ] = useState ( false ) ;
const [ activeTabKey , setActiveTabKey ] = useState ( 'basic' ) ;
const [ advancedSectionKey , setAdvancedSectionKey ] = useState ( 'all' ) ;
const [ defaultCert , setDefaultCert ] = useState ( '' ) ;
const [ defaultKey , setDefaultKey ] = useState ( '' ) ;
const [ fallbacks , setFallbacks ] = useState < FallbackRow [ ] > ( [ ] ) ;
const [ fallbackEditing , setFallbackEditing ] = useState < Set < string > > ( new Set ( ) ) ;
const isVlessLike = inboundRef . current ? . protocol === Protocols . VLESS ;
const isFallbackHost = useMemo ( ( ) = > {
const ib = inboundRef . current ;
if ( ! ib ) return false ;
if ( ib . protocol !== Protocols . VLESS && ib . protocol !== Protocols . TROJAN ) return false ;
if ( ib . stream ? . network !== 'tcp' ) return false ;
const sec = ib . stream ? . security ;
return sec === 'tls' || sec === 'reality' ;
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ inboundRef . current ? . protocol , inboundRef . current ? . stream ? . network , inboundRef . current ? . stream ? . security ] ) ;
const canEnableStream = inboundRef . current ? . canEnableStream ? . ( ) === true ;
const canEnableTls = inboundRef . current ? . canEnableTls ? . ( ) === true ;
const canEnableReality = inboundRef . current ? . canEnableReality ? . ( ) === true ;
const isNodeEligible = NODE_ELIGIBLE_PROTOCOLS . has ( inboundRef . current ? . protocol ) ;
const hasProtocolTabContent = useMemo ( ( ) = > {
const ib = inboundRef . current ;
if ( ! ib ) return false ;
if ( ib . protocol === Protocols . VLESS ) return true ;
if ( isFallbackHost ) return true ;
switch ( ib . protocol ) {
case Protocols . SHADOWSOCKS :
case Protocols . HTTP :
case Protocols . MIXED :
case Protocols . TUNNEL :
case Protocols . TUN :
case Protocols . WIREGUARD :
return true ;
default :
return false ;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ inboundRef . current ? . protocol , isFallbackHost ] ) ;
const externalProxyOn = Array . isArray ( inboundRef . current ? . stream ? . externalProxy )
&& inboundRef . current . stream . externalProxy . length > 0 ;
const stampAdvancedTextFor = useCallback ( ( slice : 'stream' | 'sniffing' | 'settings' ) = > {
const ib = inboundRef . current ;
if ( ! ib ) return ;
if ( slice === 'stream' && ! ib . canEnableStream ? . ( ) ) {
advancedTextRef . current . stream = '{}' ;
return ;
}
const obj = ib [ slice ] ;
if ( ! obj ) return ;
try {
advancedTextRef . current [ slice ] = JSON . stringify ( JSON . parse ( obj . toString ( ) ) , null , 2 ) ;
} catch {
/* keep prior */
}
} , [ ] ) ;
const primeAdvancedJson = useCallback ( ( ) = > {
( [ 'stream' , 'sniffing' , 'settings' ] as const ) . forEach ( stampAdvancedTextFor ) ;
} , [ stampAdvancedTextFor ] ) ;
const loadFallbacks = useCallback ( async ( masterId : number | null ) = > {
if ( ! masterId ) {
setFallbacks ( [ ] ) ;
return ;
}
const msg = await HttpUtil . get ( ` /panel/api/inbounds/ ${ masterId } /fallbacks ` ) ;
if ( ! msg ? . success || ! Array . isArray ( msg . obj ) ) {
setFallbacks ( [ ] ) ;
return ;
}
setFallbacks (
( msg . obj as { childId : number ; name? : string ; alpn? : string ; path? : string ; xver? : number } [ ] ) . map ( ( r ) = > ( {
rowKey : ` fb- ${ ++ fallbackKeyRef . current } ` ,
childId : r.childId ,
name : r.name || '' ,
alpn : r.alpn || '' ,
path : r.path || '' ,
xver : r.xver || 0 ,
} ) ) ,
) ;
} , [ ] ) ;
const fetchDefaultCertSettings = useCallback ( async ( ) = > {
try {
const msg = await HttpUtil . post ( '/panel/setting/defaultSettings' ) ;
if ( msg ? . success && msg . obj ) {
const obj = msg . obj as { defaultCert? : string ; defaultKey? : string } ;
setDefaultCert ( obj . defaultCert || '' ) ;
setDefaultKey ( obj . defaultKey || '' ) ;
}
} catch {
/* non-fatal */
}
} , [ ] ) ;
useEffect ( ( ) = > {
if ( ! open ) return ;
setFallbackEditing ( new Set ( ) ) ;
if ( mode === 'edit' && dbInbound ) {
const parsed = ( Inbound as any ) . fromJson ( dbInbound . toInbound ( ) . toJson ( ) ) ;
inboundRef . current = parsed ;
dbFormRef . current = new ( DBInbound as any ) ( dbInbound ) ;
primeAdvancedJson ( ) ;
if ( dbInbound . protocol === Protocols . VLESS || dbInbound . protocol === Protocols . TROJAN ) {
loadFallbacks ( dbInbound . id ) ;
} else {
setFallbacks ( [ ] ) ;
}
} else {
const ib = new ( Inbound as any ) ( ) ;
ib . protocol = Protocols . VLESS ;
ib . settings = ( Inbound as any ) . Settings . getSettings ( Protocols . VLESS ) ;
ib . port = RandomUtil . randomInteger ( 10000 , 60000 ) ;
inboundRef . current = ib ;
const form = new ( DBInbound as any ) ( ) ;
form . enable = true ;
form . remark = '' ;
form . total = 0 ;
form . expiryTime = 0 ;
form . trafficReset = 'never' ;
dbFormRef . current = form ;
primeAdvancedJson ( ) ;
setFallbacks ( [ ] ) ;
}
setActiveTabKey ( 'basic' ) ;
setAdvancedSectionKey ( 'all' ) ;
fetchDefaultCertSettings ( ) ;
refresh ( ) ;
} , [ open , mode , dbInbound , primeAdvancedJson , loadFallbacks , fetchDefaultCertSettings , refresh ] ) ;
const setExternalProxy = useCallback ( ( on : boolean ) = > {
const ib = inboundRef . current ;
if ( ! ib ? . stream ) return ;
if ( on ) {
ib . stream . externalProxy = [ {
forceTls : 'same' ,
dest : window.location.hostname ,
port : ib.port ,
remark : '' ,
} ] ;
} else {
ib . stream . externalProxy = [ ] ;
}
refresh ( ) ;
} , [ refresh ] ) ;
const onProtocolChange = useCallback ( ( next : string ) = > {
const ib = inboundRef . current ;
if ( mode === 'edit' || ! ib ) return ;
ib . protocol = next ;
ib . settings = ( Inbound as any ) . Settings . getSettings ( next ) ;
if ( ! NODE_ELIGIBLE_PROTOCOLS . has ( next ) && dbFormRef . current ) {
dbFormRef . current . nodeId = null ;
}
primeAdvancedJson ( ) ;
refresh ( ) ;
} , [ mode , primeAdvancedJson , refresh ] ) ;
const onNetworkChange = useCallback ( ( next : string ) = > {
const ib = inboundRef . current ;
if ( ! ib ? . stream ) return ;
ib . stream . network = next ;
if ( ! ib . canEnableTls ( ) ) ib . stream . security = 'none' ;
if ( ! ib . canEnableReality ( ) ) ib . reality = false ;
if (
ib . protocol === Protocols . VLESS
&& ! ib . canEnableTlsFlow ( )
&& Array . isArray ( ib . settings . vlesses )
) {
ib . settings . vlesses . forEach ( ( c : any ) = > { c . flow = '' ; } ) ;
}
if ( next !== 'kcp' && ib . stream . finalmask ) {
ib . stream . finalmask . udp = [ ] ;
}
stampAdvancedTextFor ( 'stream' ) ;
refresh ( ) ;
} , [ stampAdvancedTextFor , refresh ] ) ;
const setSecurity = useCallback ( ( v : string ) = > {
const ib = inboundRef . current ;
if ( ib ? . stream ) {
ib . stream . security = v ;
refresh ( ) ;
}
} , [ refresh ] ) ;
const addFallback = useCallback ( ( childId : number | null = null ) = > {
const row : FallbackRow = {
rowKey : ` fb- ${ ++ fallbackKeyRef . current } ` ,
childId : childId || null ,
name : '' ,
alpn : '' ,
path : '' ,
xver : 0 ,
} ;
if ( childId ) {
const child = ( dbInbounds || [ ] ) . find ( ( ib : any ) = > ib . id === childId ) ;
Object . assign ( row , deriveFallbackDefaults ( child ) ) ;
}
setFallbacks ( ( prev ) = > [ . . . prev , row ] ) ;
} , [ dbInbounds ] ) ;
const removeFallback = useCallback ( ( idx : number ) = > {
setFallbacks ( ( prev ) = > prev . filter ( ( _ , i ) = > i !== idx ) ) ;
} , [ ] ) ;
const moveFallback = useCallback ( ( idx : number , dir : number ) = > {
setFallbacks ( ( prev ) = > {
const arr = [ . . . prev ] ;
const j = idx + dir ;
if ( j < 0 || j >= arr . length ) return prev ;
[ arr [ idx ] , arr [ j ] ] = [ arr [ j ] , arr [ idx ] ] ;
return arr ;
} ) ;
} , [ ] ) ;
const onFallbackChildPicked = useCallback ( ( rowKey : string , childId : number ) = > {
setFallbacks ( ( prev ) = > prev . map ( ( row ) = > {
if ( row . rowKey !== rowKey ) return row ;
const child = ( dbInbounds || [ ] ) . find ( ( ib : any ) = > ib . id === childId ) ;
const defaults = deriveFallbackDefaults ( child ) ;
return { . . . row , childId , . . . defaults } ;
} ) ) ;
} , [ dbInbounds ] ) ;
const updateFallback = useCallback ( ( rowKey : string , patch : Partial < FallbackRow > ) = > {
setFallbacks ( ( prev ) = > prev . map ( ( row ) = > ( row . rowKey === rowKey ? { . . . row , . . . patch } : row ) ) ) ;
} , [ ] ) ;
const rederiveFallback = useCallback ( ( rowKey : string ) = > {
setFallbacks ( ( prev ) = > prev . map ( ( row ) = > {
if ( row . rowKey !== rowKey || ! row . childId ) return row ;
const child = ( dbInbounds || [ ] ) . find ( ( ib : any ) = > ib . id === row . childId ) ;
const defaults = deriveFallbackDefaults ( child ) ;
return { . . . row , . . . defaults } ;
} ) ) ;
messageApi . success ( t ( 'pages.inbounds.fallbacks.rederived' ) || 'Re-filled from child' ) ;
} , [ dbInbounds , t , messageApi ] ) ;
const quickAddAllFallbacks = useCallback ( ( ) = > {
const masterId = dbInbound ? . id ;
const list = dbInbounds || [ ] ;
setFallbacks ( ( prev ) = > {
const existing = new Set ( prev . map ( ( r ) = > r . childId ) . filter ( Boolean ) ) ;
const next = [ . . . prev ] ;
let added = 0 ;
for ( const ib of list ) {
if ( ib . id === masterId ) continue ;
if ( existing . has ( ib . id ) ) continue ;
let stream : any ;
try { stream = ib . toInbound ( ) ? . stream ; } catch { continue ; }
if ( ! stream || ! FALLBACK_ELIGIBLE_TRANSPORTS . has ( stream . network ) ) continue ;
const row : FallbackRow = {
rowKey : ` fb- ${ ++ fallbackKeyRef . current } ` ,
childId : ib.id ,
. . . deriveFallbackDefaults ( ib ) ,
} ;
next . push ( row ) ;
added += 1 ;
}
if ( added > 0 ) {
messageApi . success ( t ( 'pages.inbounds.fallbacks.quickAdded' , { n : added } ) || ` Added ${ added } fallback(s) ` ) ;
} else {
messageApi . info ( t ( 'pages.inbounds.fallbacks.quickAddedNone' ) || 'No new eligible inbounds to add' ) ;
}
return next ;
} ) ;
} , [ dbInbound , dbInbounds , t , messageApi ] ) ;
const fallbackChildOptions = useMemo ( ( ) = > {
const list = dbInbounds || [ ] ;
const masterId = dbInbound ? . id ;
return list
. filter ( ( ib : any ) = > ib . id !== masterId )
. map ( ( ib : any ) = > ( {
label : ` ${ ib . remark || ` # ${ ib . id } ` } · ${ ib . protocol } : ${ ib . port } ` ,
value : ib.id ,
} ) ) ;
} , [ dbInbounds , dbInbound ] ) ;
const toggleFallbackEdit = useCallback ( ( rowKey : string ) = > {
setFallbackEditing ( ( prev ) = > {
const next = new Set ( prev ) ;
if ( next . has ( rowKey ) ) next . delete ( rowKey ) ; else next . add ( rowKey ) ;
return next ;
} ) ;
} , [ ] ) ;
const describeFallback = useCallback ( ( record : FallbackRow ) = > {
const parts : string [ ] = [ ] ;
if ( record . name ) parts . push ( ` SNI= ${ record . name } ` ) ;
if ( record . alpn ) parts . push ( ` ALPN= ${ record . alpn } ` ) ;
if ( record . path ) parts . push ( ` path= ${ record . path } ` ) ;
const condition = parts . length
? ` ${ t ( 'pages.inbounds.fallbacks.routesWhen' ) || 'Routes when' } ${ parts . join ( ' · ' ) } `
: ( t ( 'pages.inbounds.fallbacks.defaultCatchAll' ) || 'Default — catches anything else' ) ;
const proxyTag = record . xver === 2 ? ' · PROXY v2' : record . xver === 1 ? ' · PROXY v1' : '' ;
return { condition , proxyTag } ;
} , [ t ] ) ;
const withSaving = useCallback ( async < T , > ( fn : ( ) = > Promise < T > ) : Promise < T > = > {
setSaving ( true ) ;
try { return await fn ( ) ; } finally { setSaving ( false ) ; }
} , [ ] ) ;
const randomSSPassword = useCallback ( ( target : any ) = > {
if ( target ) {
target . password = ( RandomUtil as any ) . randomShadowsocksPassword ( inboundRef . current . settings . method ) ;
refresh ( ) ;
}
} , [ refresh ] ) ;
const regenWgKeypair = useCallback ( ( target : any ) = > {
const kp = ( Wireguard as any ) . generateKeypair ( ) ;
target . publicKey = kp . publicKey ;
target . privateKey = kp . privateKey ;
refresh ( ) ;
} , [ refresh ] ) ;
const regenInboundWg = useCallback ( ( ) = > {
const kp = ( Wireguard as any ) . generateKeypair ( ) ;
inboundRef . current . settings . pubKey = kp . publicKey ;
inboundRef . current . settings . secretKey = kp . privateKey ;
refresh ( ) ;
} , [ refresh ] ) ;
const genRealityKeypair = useCallback ( async ( ) = > {
await withSaving ( async ( ) = > {
const msg = await HttpUtil . get ( '/panel/api/server/getNewX25519Cert' ) ;
if ( msg ? . success ) {
const obj = msg . obj as { privateKey : string ; publicKey : string } ;
inboundRef . current . stream . reality . privateKey = obj . privateKey ;
inboundRef . current . stream . reality . settings . publicKey = obj . publicKey ;
refresh ( ) ;
}
} ) ;
} , [ withSaving , refresh ] ) ;
const clearRealityKeypair = useCallback ( ( ) = > {
if ( ! inboundRef . current ? . stream ? . reality ) return ;
inboundRef . current . stream . reality . privateKey = '' ;
inboundRef . current . stream . reality . settings . publicKey = '' ;
refresh ( ) ;
} , [ refresh ] ) ;
const genMldsa65 = useCallback ( async ( ) = > {
await withSaving ( async ( ) = > {
const msg = await HttpUtil . get ( '/panel/api/server/getNewmldsa65' ) ;
if ( msg ? . success ) {
const obj = msg . obj as { seed : string ; verify : string } ;
inboundRef . current . stream . reality . mldsa65Seed = obj . seed ;
inboundRef . current . stream . reality . settings . mldsa65Verify = obj . verify ;
refresh ( ) ;
}
} ) ;
} , [ withSaving , refresh ] ) ;
const clearMldsa65 = useCallback ( ( ) = > {
if ( ! inboundRef . current ? . stream ? . reality ) return ;
inboundRef . current . stream . reality . mldsa65Seed = '' ;
inboundRef . current . stream . reality . settings . mldsa65Verify = '' ;
refresh ( ) ;
} , [ refresh ] ) ;
const randomizeRealityTarget = useCallback ( ( ) = > {
if ( ! inboundRef . current ? . stream ? . reality ) return ;
const target = getRandomRealityTarget ( ) as { target : string ; sni : string } ;
inboundRef . current . stream . reality . target = target . target ;
inboundRef . current . stream . reality . serverNames = target . sni ;
refresh ( ) ;
} , [ refresh ] ) ;
const randomizeShortIds = useCallback ( ( ) = > {
if ( ! inboundRef . current ? . stream ? . reality ) return ;
inboundRef . current . stream . reality . shortIds = ( RandomUtil as any ) . randomShortIds ( ) ;
refresh ( ) ;
} , [ refresh ] ) ;
const getNewEchCert = useCallback ( async ( ) = > {
if ( ! inboundRef . current ? . stream ? . tls ) return ;
await withSaving ( async ( ) = > {
const msg = await HttpUtil . post ( '/panel/api/server/getNewEchCert' , {
sni : inboundRef.current.stream.tls.sni ,
} ) ;
if ( msg ? . success ) {
const obj = msg . obj as { echServerKeys : string ; echConfigList : string } ;
inboundRef . current . stream . tls . echServerKeys = obj . echServerKeys ;
inboundRef . current . stream . tls . settings . echConfigList = obj . echConfigList ;
refresh ( ) ;
}
} ) ;
} , [ withSaving , refresh ] ) ;
const clearEchCert = useCallback ( ( ) = > {
if ( ! inboundRef . current ? . stream ? . tls ) return ;
inboundRef . current . stream . tls . echServerKeys = '' ;
inboundRef . current . stream . tls . settings . echConfigList = '' ;
refresh ( ) ;
} , [ refresh ] ) ;
const setDefaultCertData = useCallback ( ( idx : number ) = > {
if ( ! inboundRef . current ? . stream ? . tls ? . certs ? . [ idx ] ) return ;
inboundRef . current . stream . tls . certs [ idx ] . certFile = defaultCert ;
inboundRef . current . stream . tls . certs [ idx ] . keyFile = defaultKey ;
refresh ( ) ;
} , [ defaultCert , defaultKey , refresh ] ) ;
const matchesVlessAuth = useCallback ( ( block : any , authId : string ) = > {
if ( block ? . id === authId ) return true ;
const label = ( block ? . label || '' ) . toLowerCase ( ) . replace ( /[-_\s]/g , '' ) ;
if ( authId === 'mlkem768' ) return label . includes ( 'mlkem768' ) ;
if ( authId === 'x25519' ) return label . includes ( 'x25519' ) ;
return false ;
} , [ ] ) ;
const getNewVlessEnc = useCallback ( async ( authId : string ) = > {
if ( ! authId || ! inboundRef . current ? . settings ) return ;
await withSaving ( async ( ) = > {
const msg = await HttpUtil . get ( '/panel/api/server/getNewVlessEnc' ) ;
if ( ! msg ? . success ) return ;
const obj = msg . obj as { auths ? : { decryption : string ; encryption : string ; label? : string ; id? : string } [ ] } ;
const block = ( obj . auths || [ ] ) . find ( ( a ) = > matchesVlessAuth ( a , authId ) ) ;
if ( ! block ) return ;
inboundRef . current . settings . decryption = block . decryption ;
inboundRef . current . settings . encryption = block . encryption ;
refresh ( ) ;
} ) ;
} , [ withSaving , refresh , matchesVlessAuth ] ) ;
const clearVlessEnc = useCallback ( ( ) = > {
if ( ! inboundRef . current ? . settings ) return ;
inboundRef . current . settings . decryption = 'none' ;
inboundRef . current . settings . encryption = 'none' ;
refresh ( ) ;
} , [ refresh ] ) ;
const selectedVlessAuth = useMemo ( ( ) = > {
const encryption = inboundRef . current ? . settings ? . encryption ;
if ( ! encryption || encryption === 'none' ) return 'None' ;
const parts = encryption . split ( '.' ) . filter ( Boolean ) ;
const authKey = parts [ parts . length - 1 ] || '' ;
if ( ! authKey ) return t ( 'pages.inbounds.vlessAuthCustom' ) ;
return authKey . length > 300
? t ( 'pages.inbounds.vlessAuthMlkem768' )
: t ( 'pages.inbounds.vlessAuthX25519' ) ;
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ inboundRef . current ? . settings ? . encryption , t ] ) ;
const onSSMethodChange = useCallback ( ( ) = > {
const ib = inboundRef . current ;
ib . settings . password = ( RandomUtil as any ) . randomShadowsocksPassword ( ib . settings . method ) ;
if ( ib . isSSMultiUser ) {
ib . settings . shadowsockses . forEach ( ( c : any ) = > {
c . method = ib . isSS2022 ? '' : ib . settings . method ;
c . password = ( RandomUtil as any ) . randomShadowsocksPassword ( ib . settings . method ) ;
} ) ;
} else {
ib . settings . shadowsockses = [ ] ;
}
refresh ( ) ;
} , [ refresh ] ) ;
const parseAdvancedSliceOrFallback = ( rawText : string , fallback : unknown ) = > {
if ( ! rawText ? . trim ( ) ) return fallback ;
return JSON . parse ( rawText ) ;
} ;
const settingsFallback = ( ) = > inboundRef . current ? . settings ? . toJson ? . ( ) || { } ;
const sniffingFallback = ( ) = > inboundRef . current ? . sniffing ? . toJson ? . ( ) || { } ;
const streamFallback = ( ) = > inboundRef . current ? . stream ? . toJson ? . ( ) || { } ;
const parseAdvancedSliceWithLabel = useCallback ( ( rawText : string , fallback : unknown , label : string ) = > {
try {
return parseAdvancedSliceOrFallback ( rawText , fallback ) ;
} catch ( e ) {
messageApi . error ( ` ${ label } JSON invalid: ${ ( e as Error ) . message } ` ) ;
throw e ;
}
} , [ messageApi ] ) ;
const compactAdvancedJson = useCallback ( ( raw : string , fallback : string , label : string ) = > {
try {
return JSON . stringify ( JSON . parse ( raw || fallback ) ) ;
} catch ( e ) {
messageApi . error ( ` ${ label } JSON invalid: ${ ( e as Error ) . message } ` ) ;
throw e ;
}
} , [ messageApi ] ) ;
const applyAdvancedJsonToBasic = useCallback ( ( ) : boolean = > {
const ib = inboundRef . current ;
if ( ! ib ) return true ;
let settings : unknown ;
let streamSettings : unknown ;
let sniffing : unknown ;
try {
settings = parseAdvancedSliceWithLabel ( advancedTextRef . current . settings , settingsFallback ( ) , t ( 'pages.inbounds.advanced.settings' ) ) ;
streamSettings = parseAdvancedSliceWithLabel ( advancedTextRef . current . stream , streamFallback ( ) , t ( 'pages.inbounds.advanced.stream' ) ) ;
sniffing = parseAdvancedSliceWithLabel ( advancedTextRef . current . sniffing , sniffingFallback ( ) , t ( 'pages.inbounds.advanced.sniffing' ) ) ;
} catch {
return false ;
}
try {
inboundRef . current = ( Inbound as any ) . fromJson ( {
port : ib.port ,
listen : ib.listen ,
protocol : ib.protocol ,
settings ,
streamSettings ,
tag : ib.tag ,
sniffing ,
clientStats : ib.clientStats ,
} ) ;
refresh ( ) ;
} catch ( e ) {
messageApi . error ( ` ${ t ( 'pages.inbounds.advanced.jsonErrorPrefix' ) } : ${ ( e as Error ) . message } ` ) ;
return false ;
}
return true ;
} , [ t , refresh , parseAdvancedSliceWithLabel , messageApi ] ) ;
const handleTabChange = ( next : string ) = > {
if ( document . activeElement instanceof HTMLElement ) {
document . activeElement . blur ( ) ;
}
if ( activeTabKey === 'advanced' && next !== 'advanced' ) {
if ( ! applyAdvancedJsonToBasic ( ) ) return ;
}
setActiveTabKey ( next ) ;
} ;
const unwrapWrappedObject = ( parsed : unknown , key : string ) : unknown = > {
if (
parsed
&& typeof parsed === 'object'
&& ! Array . isArray ( parsed )
&& ( parsed as Record < string , unknown > ) [ key ] !== undefined
) {
return ( parsed as Record < string , unknown > ) [ key ] ;
}
return parsed ;
} ;
const wrappedConfigValue = ( key : string , slice : 'stream' | 'sniffing' | 'settings' ) : string = > {
const ib = inboundRef . current ;
if ( ! ib ) return '' ;
try {
const fb = slice === 'settings' ? settingsFallback ( ) : slice === 'sniffing' ? sniffingFallback ( ) : streamFallback ( ) ;
const value = parseAdvancedSliceOrFallback ( advancedTextRef . current [ slice ] , fb ) ;
return JSON . stringify ( { [ key ] : value } , null , 2 ) ;
} catch {
return '' ;
}
} ;
const setWrappedConfigValue = ( key : string , slice : 'stream' | 'sniffing' | 'settings' , label : string , next : string ) = > {
let parsed : unknown ;
try {
parsed = JSON . parse ( next ) ;
} catch ( e ) {
messageApi . error ( ` ${ label } JSON invalid: ${ ( e as Error ) . message } ` ) ;
return ;
}
const unwrapped = unwrapWrappedObject ( parsed , key ) ;
if ( ! unwrapped || typeof unwrapped !== 'object' || Array . isArray ( unwrapped ) ) {
messageApi . error ( ` ${ label } JSON must be an object or { ${ key } : { ... } }. ` ) ;
return ;
}
try {
advancedTextRef . current [ slice ] = JSON . stringify ( unwrapped , null , 2 ) ;
refresh ( ) ;
} catch ( e ) {
messageApi . error ( ` ${ label } JSON invalid: ${ ( e as Error ) . message } ` ) ;
}
} ;
const advancedAllValue = ( ( ) = > {
const ib = inboundRef . current ;
if ( ! ib ) return '' ;
try {
const result : Record < string , unknown > = {
listen : ib.listen ,
port : ib.port ,
protocol : ib.protocol ,
settings : parseAdvancedSliceOrFallback ( advancedTextRef . current . settings , settingsFallback ( ) ) ,
sniffing : parseAdvancedSliceOrFallback ( advancedTextRef . current . sniffing , sniffingFallback ( ) ) ,
tag : ib.tag ,
} ;
if ( canEnableStream ) {
result . streamSettings = parseAdvancedSliceOrFallback ( advancedTextRef . current . stream , streamFallback ( ) ) ;
}
return JSON . stringify ( result , null , 2 ) ;
} catch {
return '' ;
}
} ) ( ) ;
const setAdvancedAllValue = ( next : string ) = > {
let parsed : any ;
try {
parsed = JSON . parse ( next ) ;
} catch ( e ) {
messageApi . error ( ` All JSON invalid: ${ ( e as Error ) . message } ` ) ;
return ;
}
if ( ! parsed || typeof parsed !== 'object' || Array . isArray ( parsed ) ) {
messageApi . error ( 'All JSON must be an inbound object.' ) ;
return ;
}
const ib = inboundRef . current ;
try {
if ( typeof parsed . listen === 'string' ) ib . listen = parsed . listen ;
if ( parsed . port !== undefined ) {
const port = Number ( parsed . port ) ;
if ( Number . isFinite ( port ) ) ib . port = port ;
}
if ( typeof parsed . protocol === 'string' && PROTOCOLS . includes ( parsed . protocol ) ) {
ib . protocol = parsed . protocol ;
}
if ( typeof parsed . tag === 'string' ) ib . tag = parsed . tag ;
const existingSettings = parseAdvancedSliceOrFallback ( advancedTextRef . current . settings , settingsFallback ( ) ) ;
advancedTextRef . current . settings = JSON . stringify ( parsed . settings ? ? existingSettings , null , 2 ) ;
advancedTextRef . current . sniffing = JSON . stringify ( parsed . sniffing ? ? sniffingFallback ( ) , null , 2 ) ;
advancedTextRef . current . stream = canEnableStream
? JSON . stringify ( parsed . streamSettings ? ? streamFallback ( ) , null , 2 )
: '{}' ;
refresh ( ) ;
} catch ( e ) {
messageApi . error ( ` All JSON invalid: ${ ( e as Error ) . message } ` ) ;
}
} ;
const saveFallbacks = useCallback ( async ( masterId : number ) = > {
if ( ! masterId ) return true ;
const payload = {
fallbacks : fallbacks
. filter ( ( c ) = > c . childId )
. map ( ( c , i ) = > ( {
childId : c.childId ,
name : c.name ,
alpn : c.alpn ,
path : c.path ,
xver : Number ( c . xver ) || 0 ,
sortOrder : i ,
} ) ) ,
} ;
const msg = await HttpUtil . post (
` /panel/api/inbounds/ ${ masterId } /fallbacks ` ,
payload ,
{ headers : { 'Content-Type' : 'application/json' } } ,
) ;
return ! ! msg ? . success ;
} , [ fallbacks ] ) ;
const submit = useCallback ( async ( ) = > {
const ib = inboundRef . current ;
const form = dbFormRef . current ;
if ( ! ib || ! form ) return ;
setSaving ( true ) ;
try {
let streamSettings : string ;
let sniffing : string ;
let settings : string ;
try {
streamSettings = canEnableStream
? compactAdvancedJson ( advancedTextRef . current . stream , '' , t ( 'pages.inbounds.advanced.stream' ) )
: ( ib . stream ? . sockopt
? JSON . stringify ( { sockopt : ib.stream.sockopt.toJson ( ) } )
: '' ) ;
sniffing = compactAdvancedJson ( advancedTextRef . current . sniffing , ib . sniffing . toString ( ) , t ( 'pages.inbounds.advanced.sniffing' ) ) ;
settings = compactAdvancedJson ( advancedTextRef . current . settings , ib . settings . toString ( ) , t ( 'pages.inbounds.advanced.settings' ) ) ;
} catch { return ; }
const payload : any = {
up : form.up || 0 ,
down : form.down || 0 ,
total : form.total ,
remark : form.remark ,
enable : form.enable ,
expiryTime : form.expiryTime ,
trafficReset : form.trafficReset ,
lastTrafficResetTime : form.lastTrafficResetTime || 0 ,
listen : ib.listen ,
port : ib.port ,
protocol : ib.protocol ,
settings ,
streamSettings ,
sniffing ,
} ;
if ( form . nodeId != null ) payload . nodeId = form . nodeId ;
const url = mode === 'edit'
? ` /panel/api/inbounds/update/ ${ dbInbound . id } `
: '/panel/api/inbounds/add' ;
const msg = await HttpUtil . post ( url , payload ) ;
if ( msg ? . success ) {
if ( isFallbackHost ) {
const masterId = mode === 'edit'
? dbInbound . id
: ( ( msg . obj as any ) ? . id || ( msg . obj as any ) ? . Id ) ;
if ( masterId ) await saveFallbacks ( masterId ) ;
}
onSaved ( ) ;
onClose ( ) ;
}
} finally {
setSaving ( false ) ;
}
} , [ canEnableStream , compactAdvancedJson , t , mode , dbInbound , isFallbackHost , saveFallbacks , onSaved , onClose ] ) ;
const protocolSnapshot = inboundRef . current ? . protocol ;
const streamSnapshot = JSON . stringify ( inboundRef . current ? . stream ? . toJson ? . ( ) || { } ) ;
const sniffingSnapshot = JSON . stringify ( inboundRef . current ? . sniffing ? . toJson ? . ( ) || { } ) ;
const settingsSnapshot = JSON . stringify ( inboundRef . current ? . settings ? . toJson ? . ( ) || { } ) ;
useEffect ( ( ) = > {
if ( ! inboundRef . current ) return ;
( [ 'stream' , 'sniffing' , 'settings' ] as const ) . forEach ( stampAdvancedTextFor ) ;
} , [ protocolSnapshot , streamSnapshot , sniffingSnapshot , settingsSnapshot , stampAdvancedTextFor ] ) ;
const title = mode === 'edit' ? t ( 'pages.inbounds.modifyInbound' ) : t ( 'pages.inbounds.addInbound' ) ;
const okText = mode === 'edit' ? t ( 'pages.clients.submitEdit' ) : t ( 'create' ) ;
const ib = inboundRef . current ;
const form = dbFormRef . current ;
if ( ! ib || ! form ) {
return < Modal open = { open } onCancel = { onClose } title = { title } footer = { null } width = { 780 } / > ;
}
const totalGB = form . total ? Math . round ( ( form . total / SizeFormatter . ONE_GB ) * 100 ) / 100 : 0 ;
const expiryDate : Dayjs | null = form . expiryTime > 0 ? dayjs ( form . expiryTime ) : null ;
const renderBasicsTab = ( ) = > (
< Form colon = { false } labelCol = { { sm : { span : 8 } } } wrapperCol = { { sm : { span : 14 } } } >
< Form.Item label = { t ( 'enable' ) } >
< Switch checked = { ! ! form . enable } onChange = { ( v ) = > { form . enable = v ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = { t ( 'pages.inbounds.remark' ) } >
< Input value = { form . remark } onChange = { ( e ) = > { form . remark = e . target . value ; refresh ( ) ; } } / >
< / Form.Item >
{ selectableNodes . length > 0 && isNodeEligible && (
< Form.Item label = { t ( 'pages.inbounds.deployTo' ) } >
< Select
value = { form . nodeId ? ? '' }
disabled = { mode === 'edit' }
placeholder = { t ( 'pages.inbounds.localPanel' ) }
allowClear
onChange = { ( v ) = > { form . nodeId = v === '' || v == null ? null : v ; refresh ( ) ; } }
>
< Select.Option value = "" > { t ( 'pages.inbounds.localPanel' ) } < / Select.Option >
{ selectableNodes . map ( ( n : NodeRecord ) = > (
< Select.Option key = { n . id } value = { n . id } disabled = { n . status === 'offline' } >
{ n . name } { n . status === 'offline' ? ' (offline)' : '' }
< / Select.Option >
) ) }
< / Select >
< / Form.Item >
) }
< Form.Item label = { t ( 'pages.inbounds.protocol' ) } >
< Select
value = { ib . protocol }
disabled = { mode === 'edit' }
onChange = { onProtocolChange }
>
{ PROTOCOLS . map ( ( p ) = > < Select.Option key = { p } value = { p } > { p } < / Select.Option > ) }
< / Select >
< / Form.Item >
< Form.Item label = { t ( 'pages.inbounds.address' ) } >
< Input
value = { ib . listen }
placeholder = { t ( 'pages.inbounds.monitorDesc' ) }
onChange = { ( e ) = > { ib . listen = e . target . value ; refresh ( ) ; } }
/ >
< / Form.Item >
< Form.Item label = { t ( 'pages.inbounds.port' ) } >
< InputNumber
value = { ib . port }
min = { 1 }
max = { 65535 }
onChange = { ( v ) = > { ib . port = Number ( v ) || 0 ; refresh ( ) ; } }
/ >
< / Form.Item >
< Form.Item label = { < Tooltip title = { t ( 'pages.inbounds.meansNoLimit' ) } > { t ( 'pages.inbounds.totalFlow' ) } < / Tooltip > } >
< InputNumber
value = { totalGB }
min = { 0 }
2026-05-23 14:27:20 +00:00
step = { 1 }
Frontend rewrite: React + TypeScript with AntD v6 (#4498)
* chore(frontend): add react+typescript toolchain alongside vue
Step 0 of the planned vue->react migration. React 19, antd 5, i18next
+ react-i18next, typescript 5, and @vitejs/plugin-react 6 are added as
dev/runtime deps alongside the existing vue stack. Both frameworks
coexist in the build until the last entry flips.
* vite.config.js: react() plugin runs next to vue(); new manualChunks
for vendor-react / vendor-antd-react / vendor-icons-react /
vendor-i18next. Existing vue chunks unchanged.
* eslint.config.js: typescript-eslint + eslint-plugin-react-hooks
rules scoped to *.{ts,tsx}; vue config untouched for *.{js,vue}.
* tsconfig.json: strict, jsx: react-jsx, moduleResolution: bundler,
allowJs: true (lets .tsx files import the remaining .js modules
during incremental migration), @/* path alias.
* env.d.ts: Vite client types + window.X_UI_BASE_PATH typing +
SubPageData shape consumed by the subscription page.
Vite stays pinned at 8.0.13 per the existing project policy. No
existing .vue/.js source files touched in this step.
eslint-plugin-react (not -hooks) is not included because its latest
release does not yet support ESLint 10. react-hooks/purity covers
the safety-critical case; revisit when the plugin updates.
* refactor(frontend): port subpage to react+ts
Step 1 of the planned vue->react migration. The standalone
subscription page (sub/sub.go renders the HTML host; React mounts
into #app) is the first entry off vue.
Introduces two shared pieces both entries (and future ones) will
use:
* src/hooks/useTheme.tsx — React Context + useTheme hook + the
same buildAntdThemeConfig (dark/ultra-dark token overrides) and
pauseAnimationsUntilLeave helper the vue version exposes. Same
localStorage keys (dark-mode, isUltraDarkThemeEnabled) and DOM
side effects (body.className, html[data-theme]) so the two stay
in sync across the coexistence period.
* src/i18n/react.ts — i18next + react-i18next loader that reads
the same web/translation/*.json files via import.meta.glob. The
vue-i18n setup in src/i18n/index.js is untouched and still serves
the remaining vue entries.
SubPage.tsx mirrors the vue version's behavior: reads
window.__SUB_PAGE_DATA__ injected by the Go sub server, renders QR
codes / descriptions / Android+iOS deep-link dropdowns, supports
theme cycle and language switch. Uses AntD v5 idioms: Descriptions
items prop, Dropdown menu prop, Layout.Content.
* refactor(frontend): port login to react+ts
Step 2 of the planned vue->react migration. The login entry is the
first to exercise AntD React's Form API (Form + Form.Item with
name/rules + onFinish) and the existing axios/CSRF interceptors
under React.
* LoginPage.tsx: same form fields, conditional 2FA input,
rotating headline ("Hello" / "Welcome to..."), drifting blob
background, theme cycle + language popover. Headline transition
switches from vue's <Transition mode=out-in> to a CSS keyframe
animation keyed off the visible word.
* entries/login.tsx: setupAxios() + applyDocumentTitle() unchanged
from the vue entry — both are framework-agnostic in src/utils
and src/api/axios-init.js.
useTheme hook, ThemeProvider, and i18n/react.ts loader introduced
in step 1 are now shared across two entries; Vite extracts them as
a small chunk in the build output.
* refactor(frontend): port api-docs to react+ts
Step 3 of the planned vue->react migration. The five api-docs files
(ApiDocsPage, CodeBlock, EndpointRow, EndpointSection, plus the
data-only endpoints.js) all move to react+ts.
Also introduces components/AppSidebar.tsx — api-docs is the first
authenticated page to need it. AppSidebar.vue stays in place for the
six remaining vue entries (settings, inbounds, clients, xray, nodes,
index); each gets switched to AppSidebar.tsx as its entry migrates.
After the last entry flips, AppSidebar.vue is deleted.
Notable transformations:
* The scroll observer that highlights the active TOC link is a
useEffect keyed on sections — re-registers whenever the visible
set changes (search filter narrows it). Same behaviour as the vue
watchEffect.
* v-html="safeInlineHtml(...)" becomes
dangerouslySetInnerHTML={{ __html: safeInlineHtml(...) }}. The
helper still escapes everything except <code> tags.
* JSON syntax highlighter in CodeBlock is unchanged — pure regex on
the escaped string, then rendered via dangerouslySetInnerHTML.
* endpoints.js stays as JS (allowJs in tsconfig); only the consumer
signatures (Endpoint, Section) are typed at the React boundary.
* AppSidebar reuses pauseAnimationsUntilLeave + useTheme from
step 1. Drawer + Sider keyed off the same localStorage flag
(isSidebarCollapsed) and DOM theme attributes the vue version
uses, so the two stay in sync during coexistence.
* refactor(frontend): port nodes to react+ts
Step 4 of the planned vue->react migration. The nodes entry brings in
the largest shared-infrastructure batch so far — every authenticated
react page from here on can lean on these.
New shared pieces (live alongside their .vue counterparts during
coexistence):
* hooks/useMediaQuery.ts — useState + resize listener
* hooks/useWebSocket.ts — wraps WebSocketClient, subscribes on mount
and unsubscribes on unmount. The underlying client is a single
module-level instance so multiple components on the same page
share one socket.
* hooks/useNodes.ts — node list state + CRUD + probe/test, including
the totals memo (online/offline/avgLatency) used by the summary card.
applyNodesEvent is the entry point for the heartbeat-pushed list.
* components/CustomStatistic.tsx — thin Statistic wrapper, prefix +
suffix slots become props.
* components/Sparkline.tsx — the SVG line chart with measured-width
axis scaling, gradient fill, tooltip overlay, and per-instance
gradient id from React.useId. ResizeObserver lifecycle is in
useEffect; the math is unchanged.
Pages:
* NodesPage — wires hooks + WebSocket together, renders summary card
+ NodeList, hosts the form modal. Uses Modal.useModal() for the
delete confirm so the dialog inherits ConfigProvider theming.
* NodeList — desktop renders a Table with expandable history rows;
mobile flips to a vertical card list whose actions live in a
bottom-right Dropdown. The IP-blur eye toggle persists across both.
* NodeFormModal — controlled form (useState object, single setForm
per change). The reset-on-open effect computes the next state
once and applies it with eslint-disable to satisfy the new
react-hooks/set-state-in-effect rule on a legitimate pattern.
* NodeHistoryPanel — polls /panel/api/nodes/history/{id}/{metric}/
{bucket} every 15s, renders cpu+mem sparklines side-by-side.
* refactor(frontend): port settings to react+ts
Step 5 of the planned vue->react migration. Settings is the first
entry whose state model didn't translate to the Vue-style "parent
passes a reactive object, children mutate it in place" pattern, so
the React port flips it to lifted state + a typed updateSetting
patch function.
* models/setting.ts — typed AllSetting class with the same field
defaults and equals() behavior the vue version had. The .js
twin is deleted; nothing else imported it.
* hooks/useAllSetting.ts — owns allSetting + oldAllSetting state,
exposes updateSetting(patch), saveDisabled is derived via useMemo
off equals() (no more 1Hz dirty-check timer).
* components/SettingListItem.tsx — children-based wrapper instead
of named slots. The vue twin stays alive because xray (BasicsTab,
DnsTab) still imports it; deleted when xray migrates.
The five tab components and the TwoFactorModal each accept
{ allSetting, updateSetting } and render with AntD v5's Collapse
items[] API. Every v-model:value="x" became
value={...} onChange={(e) => updateSetting({ key: e.target.value })}
or onChange={(v) => updateSetting({ key: v })} for non-input
controls.
SubscriptionFormatsTab is the trickiest — fragment / noises[] /
mux / direct routing rules are stored as JSON-encoded strings on
the wire. Parsing them once via useMemo per field, mutating the
parsed object on edit, and stringifying back into the patch keeps
the round-trip identical to the vue version.
SettingsPage hosts the tab navigation (with hash sync), the
save / restart action bar, the security-warnings alert banner,
and the restart flow that rebuilds the panel URL after the new
host/port/cert settings take effect.
* refactor(frontend): port clients to react+ts
Step 6 of the planned vue->react migration. Clients is the biggest
data-CRUD page in the panel (1.1k-line ClientsPage, 4 modals, full
table + mobile card list, WebSocket-driven realtime traffic + online
updates).
New shared infra (lives alongside vue twins until inbounds migrates):
* hooks/useClients.ts — clients + inbounds list, CRUD + bulk delete +
attach/detach + traffic reset, with WebSocket event handlers
(traffic, client_stats, invalidate) and a small debounced refresh
on the invalidate event. State managed via setState; the live
client_stats event merges traffic snapshots row-by-row through a
ref to avoid stale closure issues.
* hooks/useDatepicker.ts — singleton "gregorian"/"jalalian" cache
with subscribe/notify so multiple components can read the panel's
Calendar Type without re-fetching. Mirrors useDatepicker.js.
* components/DateTimePicker.tsx — AntD DatePicker wrapper.
vue3-persian-datetime-picker has no React port; the Jalali UI
calendar is deferred (read-only Jalali display via IntlUtil
formatDate still works). The vue twin stays for inbounds.
* pages/inbounds/QrPanel.tsx — copy/download/copy-as-png QR helper
shared between clients (qr modal) and inbounds (still on vue).
Vue twin stays alive at QrPanel.vue.
* models/inbound.ts — slim port: only the TLS_FLOW_CONTROL constant
the clients form needs. The full inbound model stays as
inbound.js for now; inbounds will pull it in as inbound.ts.
The clients page itself uses Modal.useModal() for all confirm
dialogs (delete, bulk-delete, reset-traffic, delDepleted, reset-all)
so the dialogs render themed. Filter state persists to
localStorage under clientsFilterState. Sort + pagination state is
local; pageSize seeds from /panel/setting/defaultSettings.
The four modals share a controlled "open/onOpenChange" pattern
that replaces vue's v-model:open. ClientFormModal computes
attach/detach diffs from the inbound multi-select on submit; the
parent's onSave callback routes them through useClients's attach()/
detach() after the main update succeeds.
ESLint config: turned off four react-hooks v7 rules
(react-compiler, preserve-manual-memoization, set-state-in-effect,
purity). They're all React-Compiler-driven informational rules; we
don't run the compiler and the patterns they flag (initial-fetch
useEffect, derived computations using Date.now, inline arrow event
handlers) are all idiomatic React. Disabling globally instead of
per-line keeps the diff readable.
* refactor(frontend): port index dashboard to react+ts
Step 7 of the Vue→React migration. Ports the overview/index entry: dashboard
page, status + xray cards, panel-update / log / backup / system-history /
xray-metrics / xray-log / version modals, and the custom-geo subsection. Adds
the shared JsonEditor (CodeMirror 6) and useStatus hook used by the config
modal. Removes the unused react-hooks/set-state-in-effect disables now that
the rule is off globally.
* refactor(frontend): port xray to react+ts
Step 8 of the Vue→React migration. Ports the xray config entry: page shell,
basics/routing/outbounds/balancers/dns tabs, the rule + balancer + dns server
+ dns presets + warp + nord modals, the protocol-aware outbound form, and the
shared FinalMaskForm (TCP/UDP masks + QUIC params). Adds useXraySetting that
mirrors the legacy two-way sync between the JSON template string and the
parsed templateSettings tree. The outbound model itself stays in JS so the
class-driven form keeps its existing mutation API; instance access is typed
loosely inside the form to match.
The shared FinalMaskForm.vue and JsonEditor.vue stay alongside the new .tsx
versions until step 9 — InboundFormModal.vue still imports them.
Adds react-hooks/immutability and react-hooks/refs to the already-disabled
react-compiler rule set; both flag the outbound form's instance-mutation
pattern that doesn't run through useState.
* Upgrade frontend deps (antd v6, i18n, TS)
Bump frontend dependencies in package.json and regenerate package-lock.json. Notable updates: upgrade antd to v6, update i18next/react-i18next, axios, qs, vue-i18n, TypeScript and ESLint, plus related @rc-component packages and replacements (e.g. classnames/rc-util -> clsx/@rc-component/util). Lockfile changes reflect the new dependency tree required for Ant Design v6 and other package upgrades.
* refactor(frontend): port inbounds to react+ts and drop vue toolchain
Step 9 — the last entry. Ports the inbounds entry: page shell, list with
desktop table + mobile cards, info modal, qr-code modal, share-link
helpers, and the protocol-aware form modal (basics / protocol /
stream / security / sniffing / advanced JSON). useInbounds replaces
the Vue composable with WebSocket-driven traffic + client-stats merge.
Inbound and DBInbound models stay in JS so the class-driven form keeps
its mutation API; instance access is typed loosely inside the form to
match. FinalMaskForm/JsonEditor/TextModal/PromptModal/InfinityIcon are
the last shared bits to flip; their .vue counterparts go too.
Toolchain cleanup now that no entry needs Vue: drop plugin-vue from
vite.config, remove the .vue lint block + parser, prune vue / vue-i18n
/ ant-design-vue / @ant-design/icons-vue / vue3-persian-datetime-picker
/ moment-jalaali override from package.json, and switch utils/index.js
to import { message } from 'antd' instead of ant-design-vue.
* chore(frontend): adopt antd v6 api updates
Sweep deprecated props across the React tree:
- Modal: destroyOnClose -> destroyOnHidden, maskClosable -> mask.closable
- Space: direction -> orientation (or removed when redundant)
- Input.Group compact -> Space.Compact block
- Drawer: width -> size
- Spin: tip -> description
- Progress: trailColor -> railColor
- Alert: message -> title
- Popover: overlayClassName -> rootClassName
- BackTop -> FloatButton.BackTop
Also refresh dashboard theming for v6: rename dark/ultra Layout and Menu
tokens (siderBg, darkItemBg, darkSubMenuItemBg, darkPopupBg), tweak gauge
size/stroke, add font-size overrides for Statistic and Progress so the
overview numbers stay legible under v6 defaults.
* chore(frontend): antd v6 polish, theme + modal fixes
- adopt message.useMessage hook + messageBus bridge so HttpUtil messages
inherit ConfigProvider theme tokens
- replace deprecated antd APIs (List, Input addonBefore/After, Empty
imageStyle); introduce InputAddon helper + SettingListItem custom rows
- fix dark/ultra selectors in portaled modals (body.dark,
html[data-theme='ultra-dark']) instead of nonexistent .is-dark/.is-ultra
- add horizontal scroll to clients table; reorder node columns so
actions+enable sit at the left
- swap raw button for antd Button in NodeFormModal test connection
- fix FinalMaskForm nested-form by hoisting it outside OutboundFormModal's
parent Form
- fix advanced "all" JSON tab in InboundFormModal — useMemo on a mutated
ref was stale; compute on every render
- fix chart-on-open for SystemHistory + XrayMetrics modals by adding open
to effect deps (useRef.current doesn't trigger re-runs)
- switch i18next interpolation to single-brace {var} to match locale files
- drop residual Vue mentions in CI workflows and Go comments
* fix(frontend): qr code collapse — open only first panel, allow toggle
ClientQrModal and QrCodeModal both used activeKey without onChange,
forcing every panel open and blocking user toggle. Switch to controlled
state initialized to the first item's key on open, with onChange so
clicks update state.
Also remove unused AppBridge.tsx (superseded by per-page message.useMessage
hook).
* fix(frontend): hover cards, balancer load, routing dnd, modal a11y, outbound crash
- ClientsPage/SettingsPage/XrayPage: add hoverable to bottom card/tabs so
hover affordance matches the top card
- BalancerFormModal: lazy-init useState from props + destroyOnHidden so
the form mounts with saved values instead of relying on a useEffect
sync that could miss the first open
- RoutingTab: rewrite pointer drag — handlers are now defined inside the
pointerdown closure so addEventListener/removeEventListener match;
drag state lives on a ref (from/to/moved) so onUp reads the real
indices, not stale closure values. Adds setPointerCapture so Windows
and touch keep delivering events when the cursor leaves the handle.
- OutboundFormModal/InboundFormModal: blur the focused input before
switching tabs to silence the aria-hidden-on-focused-element warning
- utils.isArrEmpty: return true for undefined/null arrays — the old form
treated undefined as "not empty" which crashed VLESSSettings.fromJson
when json.vnext was missing
* fix(frontend): clipboard reliability + restyle login page
- ClipboardManager.copyText: prefer navigator.clipboard on secure
contexts, fall back to a focused on-screen textarea + execCommand.
Old path used left:-9999px which failed selection in some browsers
and swallowed execCommand's return value, so the "copied" toast
appeared even when nothing made it to the clipboard.
- LoginPage: richer gradient backdrop — five animated colour blobs,
glassmorphic card (backdrop-filter blur + saturate), gradient brand
text/accent, masked grid texture for depth, and a thin gradient
border on the card. Light/dark/ultra each get their own palette.
* Memoize compactAdvancedJson and update deps
Wrap compactAdvancedJson in useCallback (dependent on messageApi) and add it to the dependency array of applyAdvancedJsonToBasic. This ensures a stable function reference for correct dependency tracking and avoids stale closures/unnecessary re-renders in InboundFormModal.tsx.
* style(frontend): prettier charts, drop redundant frame, format net rates
- Sparkline: multi-stop gradient fill, soft drop-shadow under the line,
dashed grid, glowing pulse on the latest-point marker, pill-shaped
tooltip with dashed crosshair
- XrayMetricsModal: glow + pulse on the observatory alive dot,
monospace stamps/listen text
- SystemHistoryModal: keep just the modal's frame around the chart (the
inner wrapper I'd added stacked a second border on top); strip the
decimal from Net Up/Down (25.63 KB/s → 25 KB/s) only on this chart's
formatter
* style(frontend): refined dark/ultra palette + shared pro card frame
- Dark tokens shifted to a cooler, Linear-style palette: page #1a1b1f,
sidebar/header #15161a (recessed nav, darker than cards), card
#23252b, elevated #2d2f37
- Ultra dark: page pure #000 for OLED, sidebar #050507 disappears into
the frame, card #101013 with a clear step, elevated #1a1a1e
- New styles/page-cards.css holds the card border/shadow/hover rules so
all seven content pages (index, clients, inbounds, xray, settings,
nodes, api-docs) share one definition instead of duplicating in each
page CSS
- Dashboard typography: uppercase card titles with letter-spacing,
larger 17px stat values, subtle gradient divider between stat columns,
ellipsis on action labels so "Backup & Restore" doesn't break the
card height at mid widths
- Light --bg-page stays at #e6e8ec for the contrast against white cards
* fix(frontend): wireguard info alignment, blue login dark, embed gitkeep
- align WireGuard info-modal fields with Protocol/Address/Port by wrapping
values in Tag (matches the rest of the dl.info-list rows)
- swap login dark palette from purple to pure blue blobs/accent/brand
- pin web/dist/.gitkeep through gitignore so //go:embed all:dist never
fails on a fresh clone with an empty dist directory
* docs: refresh frontend docs for the React + TS + AntD 6 stack
Update CONTRIBUTING.md and frontend/README.md to describe the migrated
frontend accurately:
- replace Vue 3 / Ant Design Vue 4 references with React 19 / AntD 6 / TS
- swap composables -> hooks, vue-i18n -> react-i18next, createApp -> createRoot
- mention the typecheck step (tsc --noEmit) in the PR checklist
- document the Vite 8.0.13 pin and TypeScript strict mode in conventions
- list the nodes and api-docs entries that were missing from the layout
* style(frontend): improve readability and mobile polish
- bump statistic title/value contrast in dark and ultra-dark so totals
on the inbounds summary card stay legible
- give index card actions explicit colors per theme so links like Stop,
Logs, System History no longer fade into the card background
- show the panel version as a tag next to "3X-UI" on mobile, mirroring
the Xray version tag pattern, and turn it orange when an update is
available
- make the login settings button a proper circle by adding size="large"
+ an explicit border-radius fallback on .toolbar-btn
* feat: jalali calendar support and date formatting fixes
- Wire useDatepicker into IntlUtil and switch jalalian display locale
to fa-IR for clean "1405/07/03 12:00:00" output (drops the awkward
"AP" era suffix that "<lang>-u-ca-persian" produced)
- Drop in persian-calendar-suite for the jalali date picker, with a
light/dark/ultra theme map and CSS overrides so the inline-styled
input stays readable and bg matches the surrounding container
- Force LTR on the picker input so "1405/03/07 00:00" reads naturally
- Pass calendar setting through ClientInfoModal, ClientsPage Duration
tooltip, and ClientFormModal's expiry picker
- Heuristic toMs() in ClientInfoModal so GORM's autoUpdateTime seconds
render as a real date instead of "1348/11/01"
- Persist UpdatedAt on the ClientRecord row in client_service.Update;
previously only the inbound settings JSON was bumped, so the panel
never saw a fresh updated_at after editing a client
* feat(frontend): donate link, panel version label, login lang menu
- Sidebar: add heart donate link to https://donate.sanaei.dev and small panel version under 3X-UI brand
- Login: swap settings-cog for translation icon, drop title, render languages as a direct list
- Vite dev: inject window.X_UI_CUR_VER from config/version so dev mode matches prod
- Translations: add menu.donate across all locales
* fix(xray-update): respect XUI_BIN_FOLDER on Windows
The Windows update path hardcoded "bin/xray-windows-amd64.exe", ignoring
the configured XUI_BIN_FOLDER. In dev mode (folder set to x-ui) this
created a stray bin/ folder while the running binary stayed un-updated.
* Bump Xray to v26.5.9 and minor cleanup
Update Xray release URLs to v26.5.9 in the GitHub Actions workflow and DockerInit.sh. Remove the hardcoded skip for tagVersion "26.5.3" so it will be considered when collecting Xray versions. Apply small formatting fixes: remove an extra blank line in database/db.go, normalize spacing/alignment of Protocol constants in database/model/model.go, and trim a trailing blank line in web/controller/inbound.go.
* fix(frontend): route remaining copy buttons through ClipboardManager
Direct navigator.clipboard calls fail in non-secure contexts (HTTP on a
LAN IP), making the API-docs code copy and security-tab token copy
silently broken. Both now go through ClipboardManager which falls back
to document.execCommand('copy') when navigator.clipboard is unavailable.
* fix(db): store CreatedAt/UpdatedAt in milliseconds
GORM's autoCreateTime/autoUpdateTime tags default to Unix seconds on
int64 fields and overwrite the service-supplied UnixMilli value on
save. The frontend interprets these timestamps as JS Date inputs
(milliseconds), so created/updated columns rendered ~1970 dates. Adding
the :milli qualifier makes GORM match what the service code and UI
expect.
* Improve legacy clipboard copy handling
Refactor ClipboardManager._legacyCopy to better handle focus and selection when copying. The textarea is now appended to the active element's parent (or body) and placed off-screen with aria-hidden and readonly attributes. The code preserves and restores the previous document selection and active element, uses focus({preventScroll: true}) to avoid scrolling, and returns the execCommand('copy') result. This makes legacy copy behavior more robust and less disruptive to the page state.
* fix(lint): drop redundant ok=false in clipboard fallback catch
* chore(deps): bump golang.org/x/net to v0.55.0 for GO-2026-5026
2026-05-23 13:21:45 +00:00
onChange = { ( v ) = > {
form . total = NumberFormatter . toFixed ( ( Number ( v ) || 0 ) * SizeFormatter . ONE_GB , 0 ) ;
refresh ( ) ;
} }
/ >
< / Form.Item >
< Form.Item label = { t ( 'pages.inbounds.periodicTrafficResetTitle' ) } >
< Select value = { form . trafficReset } onChange = { ( v ) = > { form . trafficReset = v ; refresh ( ) ; } } >
{ TRAFFIC_RESETS . map ( ( r ) = > (
< Select.Option key = { r } value = { r } > { t ( ` pages.inbounds.periodicTrafficReset. ${ r } ` ) } < / Select.Option >
) ) }
< / Select >
< / Form.Item >
< Form.Item label = { < Tooltip title = { t ( 'pages.inbounds.leaveBlankToNeverExpire' ) } > { t ( 'pages.inbounds.expireDate' ) } < / Tooltip > } >
< DateTimePicker
value = { expiryDate }
onChange = { ( d ) = > { form . expiryTime = d ? d . valueOf ( ) : 0 ; refresh ( ) ; } }
/ >
< / Form.Item >
< / Form >
) ;
const renderFallbacksCard = ( ) = > (
< Card size = "small" className = "mt-12" title = { t ( 'pages.inbounds.fallbacks.title' ) || 'Fallbacks' } >
< Paragraph type = "secondary" style = { { marginBottom : 12 } } >
{ t ( 'pages.inbounds.fallbacks.help' ) || 'When a connection on this inbound does not match any client, route it to another inbound. Pick a child below and the routing fields (SNI / ALPN / path / xver) auto-fill from its transport — most setups need no further tweaking. Each child should listen on 127.0.0.1 with security=none.' }
< / Paragraph >
{ fallbacks . length === 0 && (
< Empty description = { t ( 'pages.inbounds.fallbacks.empty' ) || 'No fallbacks yet' } styles = { { image : { height : 40 } } } style = { { margin : '8px 0 12px' } } / >
) }
{ fallbacks . map ( ( record , index ) = > (
< div key = { record . rowKey } style = { { border : '1px solid var(--app-border-tertiary)' , borderRadius : 6 , padding : '10px 12px' , marginBottom : 8 } } >
< Row gutter = { 8 } align = "middle" wrap = { false } >
< Col flex = "none" >
< Space orientation = "vertical" size = { 2 } >
< Button size = "small" type = "text" disabled = { index === 0 } onClick = { ( ) = > moveFallback ( index , - 1 ) } >
< CaretUpOutlined / >
< / Button >
< Button size = "small" type = "text" disabled = { index === fallbacks . length - 1 } onClick = { ( ) = > moveFallback ( index , 1 ) } >
< CaretDownOutlined / >
< / Button >
< / Space >
< / Col >
< Col flex = "auto" >
< Select
value = { record . childId }
options = { fallbackChildOptions }
showSearch
placeholder = { t ( 'pages.inbounds.fallbacks.pickInbound' ) || 'Pick an inbound' }
filterOption = { ( input , option ) = > ( ( option ? . label as string ) || '' ) . toLowerCase ( ) . includes ( input . toLowerCase ( ) ) }
style = { { width : '100%' } }
onChange = { ( v ) = > onFallbackChildPicked ( record . rowKey , v ) }
/ >
< Text type = "secondary" style = { { fontSize : 12 , display : 'block' , marginTop : 4 } } >
{ describeFallback ( record ) . condition } { describeFallback ( record ) . proxyTag }
< / Text >
< / Col >
< Col flex = "none" >
< Space size = { 4 } >
< Tooltip title = { t ( 'pages.inbounds.fallbacks.rederive' ) || 'Re-fill from child' } >
< Button size = "small" type = "text" disabled = { ! record . childId } onClick = { ( ) = > rederiveFallback ( record . rowKey ) } >
< SyncOutlined / >
< / Button >
< / Tooltip >
< Tooltip title = { fallbackEditing . has ( record . rowKey )
? ( t ( 'pages.inbounds.fallbacks.hideAdvanced' ) || 'Hide advanced' )
: ( t ( 'pages.inbounds.fallbacks.editAdvanced' ) || 'Edit routing fields' ) } >
< Button size = "small" type = "text" onClick = { ( ) = > toggleFallbackEdit ( record . rowKey ) } >
< SettingOutlined / >
< / Button >
< / Tooltip >
< Button size = "small" type = "text" danger onClick = { ( ) = > removeFallback ( index ) } >
< DeleteOutlined / >
< / Button >
< / Space >
< / Col >
< / Row >
{ fallbackEditing . has ( record . rowKey ) && (
< Row gutter = { 8 } style = { { marginTop : 8 } } >
< Col xs = { 24 } md = { 8 } >
< Space.Compact block >
< InputAddon > SNI < / InputAddon >
< Input placeholder = { t ( 'pages.inbounds.fallbacks.matchAny' ) || 'any' }
value = { record . name } onChange = { ( e ) = > updateFallback ( record . rowKey , { name : e.target.value } ) } / >
< / Space.Compact >
< / Col >
< Col xs = { 24 } md = { 5 } >
< Space.Compact block >
< InputAddon > ALPN < / InputAddon >
< Input placeholder = { t ( 'pages.inbounds.fallbacks.matchAny' ) || 'any' }
value = { record . alpn } onChange = { ( e ) = > updateFallback ( record . rowKey , { alpn : e.target.value } ) } / >
< / Space.Compact >
< / Col >
< Col xs = { 24 } md = { 7 } >
< Space.Compact block >
< InputAddon > Path < / InputAddon >
< Input placeholder = "/" value = { record . path }
onChange = { ( e ) = > updateFallback ( record . rowKey , { path : e.target.value } ) } / >
< / Space.Compact >
< / Col >
< Col xs = { 24 } md = { 4 } >
< Space.Compact block >
< InputAddon > xver < / InputAddon >
< InputNumber min = { 0 } max = { 2 } style = { { width : '100%' } }
value = { record . xver }
onChange = { ( v ) = > updateFallback ( record . rowKey , { xver : Number ( v ) || 0 } ) } / >
< / Space.Compact >
< / Col >
< / Row >
) }
< / div >
) ) }
< Space size = { 8 } style = { { marginTop : 4 } } wrap >
< Button size = "small" onClick = { ( ) = > addFallback ( ) } >
< PlusOutlined / > { t ( 'pages.inbounds.fallbacks.add' ) || 'Add fallback' }
< / Button >
< Button size = "small" type = "primary" ghost onClick = { quickAddAllFallbacks } >
{ t ( 'pages.inbounds.fallbacks.quickAddAll' ) || 'Quick add all eligible' }
< / Button >
< / Space >
< / Card >
) ;
const renderProtocolTab = ( ) = > (
< >
{ isVlessLike && (
< Form colon = { false } labelCol = { { sm : { span : 8 } } } wrapperCol = { { sm : { span : 14 } } } className = "mt-12" >
< Form.Item label = { t ( 'pages.inbounds.decryption' ) } >
< Input value = { ib . settings . decryption } onChange = { ( e ) = > { ib . settings . decryption = e . target . value ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = { t ( 'pages.inbounds.encryption' ) } >
< Input value = { ib . settings . encryption } onChange = { ( e ) = > { ib . settings . encryption = e . target . value ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = " " >
< Space size = { 8 } wrap >
< Button type = "primary" loading = { saving } onClick = { ( ) = > getNewVlessEnc ( 'x25519' ) } >
{ t ( 'pages.inbounds.vlessAuthX25519' ) }
< / Button >
< Button type = "primary" loading = { saving } onClick = { ( ) = > getNewVlessEnc ( 'mlkem768' ) } >
{ t ( 'pages.inbounds.vlessAuthMlkem768' ) }
< / Button >
< Button danger onClick = { clearVlessEnc } > { t ( 'clear' ) } < / Button >
< / Space >
< Text type = "secondary" className = "vless-auth-state" >
{ t ( 'pages.inbounds.vlessAuthSelected' , { auth : selectedVlessAuth } ) }
< / Text >
< / Form.Item >
< / Form >
) }
{ isFallbackHost && renderFallbacksCard ( ) }
{ ib . protocol === Protocols . SHADOWSOCKS && (
< Form colon = { false } labelCol = { { sm : { span : 8 } } } wrapperCol = { { sm : { span : 14 } } } className = "mt-12" >
< Form.Item label = "Encryption method" >
< Select value = { ib . settings . method } onChange = { ( v ) = > { ib . settings . method = v ; onSSMethodChange ( ) ; } } >
{ Object . entries ( SSMethods ) . map ( ( [ k , m ] ) = > (
< Select.Option key = { k } value = { m as string } > { k } < / Select.Option >
) ) }
< / Select >
< / Form.Item >
{ ib . isSS2022 && (
< Form.Item label = { < > Password < SyncOutlined className = "random-icon" onClick = { ( ) = > randomSSPassword ( ib . settings ) } / > < / > } >
< Input value = { ib . settings . password } onChange = { ( e ) = > { ib . settings . password = e . target . value ; refresh ( ) ; } } / >
< / Form.Item >
) }
< Form.Item label = "Network" >
< Select value = { ib . settings . network } style = { { width : 120 } } onChange = { ( v ) = > { ib . settings . network = v ; refresh ( ) ; } } >
< Select.Option value = "tcp,udp" > TCP , UDP < / Select.Option >
< Select.Option value = "tcp" > TCP < / Select.Option >
< Select.Option value = "udp" > UDP < / Select.Option >
< / Select >
< / Form.Item >
< Form.Item label = "ivCheck" >
< Switch checked = { ! ! ib . settings . ivCheck } onChange = { ( v ) = > { ib . settings . ivCheck = v ; refresh ( ) ; } } / >
< / Form.Item >
< / Form >
) }
{ ( ib . protocol === Protocols . HTTP || ib . protocol === Protocols . MIXED ) && (
< Form colon = { false } labelCol = { { sm : { span : 8 } } } wrapperCol = { { sm : { span : 14 } } } className = "mt-12" >
< Form.Item label = "Accounts" >
< Button size = "small" onClick = { ( ) = > {
const Account = ib . protocol === Protocols . HTTP
? ( Inbound as any ) . HttpSettings . HttpAccount
: ( Inbound as any ) . MixedSettings . SocksAccount ;
ib . settings . addAccount ( new Account ( ) ) ;
refresh ( ) ;
} } >
< PlusOutlined / > Add
< / Button >
< / Form.Item >
< Form.Item wrapperCol = { { span : 24 } } >
{ ( ib . settings . accounts || [ ] ) . map ( ( account : any , idx : number ) = > (
< Space.Compact key = { idx } className = "mb-8" block >
< InputAddon > { String ( idx + 1 ) } < / InputAddon >
< Input value = { account . user } placeholder = "Username"
onChange = { ( e ) = > { account . user = e . target . value ; refresh ( ) ; } } / >
< Input value = { account . pass } placeholder = "Password"
onChange = { ( e ) = > { account . pass = e . target . value ; refresh ( ) ; } } / >
< Button onClick = { ( ) = > { ib . settings . delAccount ( idx ) ; refresh ( ) ; } } >
< MinusOutlined / >
< / Button >
< / Space.Compact >
) ) }
< / Form.Item >
{ ib . protocol === Protocols . HTTP && (
< Form.Item label = "Allow transparent" >
< Switch checked = { ! ! ib . settings . allowTransparent } onChange = { ( v ) = > { ib . settings . allowTransparent = v ; refresh ( ) ; } } / >
< / Form.Item >
) }
{ ib . protocol === Protocols . MIXED && (
< >
< Form.Item label = "Auth" >
< Select value = { ib . settings . auth } onChange = { ( v ) = > { ib . settings . auth = v ; refresh ( ) ; } } >
< Select.Option value = "noauth" > noauth < / Select.Option >
< Select.Option value = "password" > password < / Select.Option >
< / Select >
< / Form.Item >
< Form.Item label = "UDP" >
< Switch checked = { ! ! ib . settings . udp } onChange = { ( v ) = > { ib . settings . udp = v ; refresh ( ) ; } } / >
< / Form.Item >
{ ib . settings . udp && (
< Form.Item label = "UDP IP" >
< Input value = { ib . settings . ip } onChange = { ( e ) = > { ib . settings . ip = e . target . value ; refresh ( ) ; } } / >
< / Form.Item >
) }
< / >
) }
< / Form >
) }
{ ib . protocol === Protocols . TUNNEL && (
< Form colon = { false } labelCol = { { sm : { span : 8 } } } wrapperCol = { { sm : { span : 14 } } } className = "mt-12" >
< Form.Item label = "Rewrite address" >
< Input value = { ib . settings . rewriteAddress } onChange = { ( e ) = > { ib . settings . rewriteAddress = e . target . value ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = "Rewrite port" >
< InputNumber value = { ib . settings . rewritePort } min = { 0 } max = { 65535 }
onChange = { ( v ) = > { ib . settings . rewritePort = Number ( v ) || 0 ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = "Allowed network" >
< Select value = { ib . settings . allowedNetwork } onChange = { ( v ) = > { ib . settings . allowedNetwork = v ; refresh ( ) ; } } >
< Select.Option value = "tcp,udp" > TCP , UDP < / Select.Option >
< Select.Option value = "tcp" > TCP < / Select.Option >
< Select.Option value = "udp" > UDP < / Select.Option >
< / Select >
< / Form.Item >
< Form.Item label = "Port map" >
< Button size = "small" onClick = { ( ) = > { ib . settings . addPortMap ( '' , '' ) ; refresh ( ) ; } } >
< PlusOutlined / >
< / Button >
< / Form.Item >
{ ( ib . settings . portMap || [ ] ) . length > 0 && (
< Form.Item wrapperCol = { { span : 24 } } >
{ ( ib . settings . portMap as { name : string ; value : string } [ ] ) . map ( ( pm , idx ) = > (
< Space.Compact key = { ` pm- ${ idx } ` } className = "mb-8" block >
< InputAddon > { String ( idx + 1 ) } < / InputAddon >
< Input value = { pm . name } placeholder = "5555"
onChange = { ( e ) = > { pm . name = e . target . value ; refresh ( ) ; } } / >
< Input value = { pm . value } placeholder = "1.1.1.1:7777"
onChange = { ( e ) = > { pm . value = e . target . value ; refresh ( ) ; } } / >
< Button onClick = { ( ) = > { ib . settings . removePortMap ( idx ) ; refresh ( ) ; } } >
< MinusOutlined / >
< / Button >
< / Space.Compact >
) ) }
< / Form.Item >
) }
< Form.Item label = "Follow redirect" >
< Switch checked = { ! ! ib . settings . followRedirect } onChange = { ( v ) = > { ib . settings . followRedirect = v ; refresh ( ) ; } } / >
< / Form.Item >
< / Form >
) }
{ ib . protocol === Protocols . TUN && (
< Form colon = { false } labelCol = { { sm : { span : 8 } } } wrapperCol = { { sm : { span : 14 } } } className = "mt-12" >
< Form.Item label = "Interface name" >
< Input value = { ib . settings . name } placeholder = "xray0"
onChange = { ( e ) = > { ib . settings . name = e . target . value ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = "MTU" >
< InputNumber value = { ib . settings . mtu } min = { 0 }
onChange = { ( v ) = > { ib . settings . mtu = Number ( v ) || 0 ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = "Gateway" >
< Button size = "small" onClick = { ( ) = > { ib . settings . gateway . push ( '' ) ; refresh ( ) ; } } >
< PlusOutlined / >
< / Button >
{ ( ib . settings . gateway || [ ] ) . map ( ( _ip : string , j : number ) = > (
< Space.Compact key = { ` tun-gw- ${ j } ` } block className = "mt-4" >
< Input
placeholder = { j === 0 ? '10.0.0.1/16' : 'fc00::1/64' }
value = { ib . settings . gateway [ j ] }
onChange = { ( e ) = > { ib . settings . gateway [ j ] = e . target . value ; refresh ( ) ; } } / >
< Button size = "small" onClick = { ( ) = > { ib . settings . gateway . splice ( j , 1 ) ; refresh ( ) ; } } >
< MinusOutlined / >
< / Button >
< / Space.Compact >
) ) }
< / Form.Item >
< Form.Item label = "DNS" >
< Button size = "small" onClick = { ( ) = > { ib . settings . dns . push ( '' ) ; refresh ( ) ; } } >
< PlusOutlined / >
< / Button >
{ ( ib . settings . dns || [ ] ) . map ( ( _ip : string , j : number ) = > (
< Space.Compact key = { ` tun-dns- ${ j } ` } block className = "mt-4" >
< Input
placeholder = { j === 0 ? '1.1.1.1' : '8.8.8.8' }
value = { ib . settings . dns [ j ] }
onChange = { ( e ) = > { ib . settings . dns [ j ] = e . target . value ; refresh ( ) ; } } / >
< Button size = "small" onClick = { ( ) = > { ib . settings . dns . splice ( j , 1 ) ; refresh ( ) ; } } >
< MinusOutlined / >
< / Button >
< / Space.Compact >
) ) }
< / Form.Item >
< Form.Item label = "User level" >
< InputNumber value = { ib . settings . userLevel } min = { 0 }
onChange = { ( v ) = > { ib . settings . userLevel = Number ( v ) || 0 ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = { < Tooltip title = "Windows-only. CIDRs added to the system routing table automatically so matching traffic goes through TUN." > Auto system routes < / Tooltip > } >
< Button size = "small" onClick = { ( ) = > { ib . settings . autoSystemRoutingTable . push ( '' ) ; refresh ( ) ; } } >
< PlusOutlined / >
< / Button >
{ ( ib . settings . autoSystemRoutingTable || [ ] ) . map ( ( _ip : string , j : number ) = > (
< Space.Compact key = { ` tun-rt- ${ j } ` } block className = "mt-4" >
< Input
placeholder = { j === 0 ? '0.0.0.0/0' : '::/0' }
value = { ib . settings . autoSystemRoutingTable [ j ] }
onChange = { ( e ) = > { ib . settings . autoSystemRoutingTable [ j ] = e . target . value ; refresh ( ) ; } } / >
< Button size = "small" onClick = { ( ) = > { ib . settings . autoSystemRoutingTable . splice ( j , 1 ) ; refresh ( ) ; } } >
< MinusOutlined / >
< / Button >
< / Space.Compact >
) ) }
< / Form.Item >
< Form.Item label = { < Tooltip title = "Physical interface for outbound traffic. Use 'auto' to detect; auto-enabled when Auto system routes is set." > Auto outbounds interface < / Tooltip > } >
< Input value = { ib . settings . autoOutboundsInterface } placeholder = "auto"
onChange = { ( e ) = > { ib . settings . autoOutboundsInterface = e . target . value ; refresh ( ) ; } } / >
< / Form.Item >
< / Form >
) }
{ ib . protocol === Protocols . WIREGUARD && (
< Form colon = { false } labelCol = { { sm : { span : 8 } } } wrapperCol = { { sm : { span : 14 } } } className = "mt-12" >
< Form.Item label = { < > Secret key < SyncOutlined className = "random-icon" onClick = { regenInboundWg } / > < / > } >
< Input value = { ib . settings . secretKey }
onChange = { ( e ) = > { ib . settings . secretKey = e . target . value ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = "Public key" >
< Input value = { ib . settings . pubKey } disabled / >
< / Form.Item >
< Form.Item label = "MTU" >
< InputNumber value = { ib . settings . mtu }
onChange = { ( v ) = > { ib . settings . mtu = Number ( v ) || 0 ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = "No-kernel TUN" >
< Switch checked = { ! ! ib . settings . noKernelTun }
onChange = { ( v ) = > { ib . settings . noKernelTun = v ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = "Peers" >
< Button size = "small" onClick = { ( ) = > { ib . settings . addPeer ( ) ; refresh ( ) ; } } >
< PlusOutlined / > Add peer
< / Button >
< / Form.Item >
{ ( ib . settings . peers || [ ] ) . map ( ( peer : any , idx : number ) = > (
< div key = { idx } className = "wg-peer" >
< Divider style = { { margin : '8px 0' } } >
Peer { idx + 1 }
{ ib . settings . peers . length > 1 && (
< DeleteOutlined className = "danger-icon" onClick = { ( ) = > { ib . settings . delPeer ( idx ) ; refresh ( ) ; } } / >
) }
< / Divider >
< Form.Item label = { < > Secret key < SyncOutlined className = "random-icon" onClick = { ( ) = > regenWgKeypair ( peer ) } / > < / > } >
< Input value = { peer . privateKey } onChange = { ( e ) = > { peer . privateKey = e . target . value ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = "Public key" >
< Input value = { peer . publicKey } onChange = { ( e ) = > { peer . publicKey = e . target . value ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = "PSK" >
< Input value = { peer . psk } onChange = { ( e ) = > { peer . psk = e . target . value ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = "Allowed IPs" >
< Button size = "small" onClick = { ( ) = > { peer . allowedIPs . push ( '' ) ; refresh ( ) ; } } >
< PlusOutlined / >
< / Button >
{ ( peer . allowedIPs || [ ] ) . map ( ( _ip : string , j : number ) = > (
< Space.Compact key = { j } block className = "mt-4" >
< Input
value = { peer . allowedIPs [ j ] }
onChange = { ( e ) = > { peer . allowedIPs [ j ] = e . target . value ; refresh ( ) ; } } / >
{ peer . allowedIPs . length > 1 && (
< Button size = "small" onClick = { ( ) = > { peer . allowedIPs . splice ( j , 1 ) ; refresh ( ) ; } } >
< MinusOutlined / >
< / Button >
) }
< / Space.Compact >
) ) }
< / Form.Item >
< Form.Item label = "Keep-alive" >
< InputNumber value = { peer . keepAlive } min = { 0 }
onChange = { ( v ) = > { peer . keepAlive = Number ( v ) || 0 ; refresh ( ) ; } } / >
< / Form.Item >
< / div >
) ) }
< / Form >
) }
< / >
) ;
const renderStreamTab = ( ) = > {
const network = ib . stream ? . network ;
return (
< >
< Form colon = { false } labelCol = { { sm : { span : 8 } } } wrapperCol = { { sm : { span : 14 } } } >
{ ib . protocol !== Protocols . HYSTERIA && (
< Form.Item label = "Transmission" >
< Select value = { network } style = { { width : '75%' } } onChange = { onNetworkChange } >
< Select.Option value = "tcp" > TCP ( RAW ) < / Select.Option >
< Select.Option value = "kcp" > mKCP < / Select.Option >
< Select.Option value = "ws" > WebSocket < / Select.Option >
< Select.Option value = "grpc" > gRPC < / Select.Option >
< Select.Option value = "httpupgrade" > HTTPUpgrade < / Select.Option >
< Select.Option value = "xhttp" > XHTTP < / Select.Option >
< / Select >
< / Form.Item >
) }
{ network === 'tcp' && (
< >
{ canEnableTls && (
< Form.Item label = "Proxy Protocol" >
< Switch checked = { ! ! ib . stream . tcp . acceptProxyProtocol }
onChange = { ( v ) = > { ib . stream . tcp . acceptProxyProtocol = v ; refresh ( ) ; } } / >
< / Form.Item >
) }
< Form.Item label = { ` HTTP ${ t ( 'camouflage' ) } ` } >
< Switch checked = { ib . stream . tcp . type === 'http' }
onChange = { ( v ) = > { ib . stream . tcp . type = v ? 'http' : 'none' ; refresh ( ) ; } } / >
< / Form.Item >
{ ib . stream . tcp . type === 'http' && (
< >
< Divider style = { { margin : 0 } } > { t ( 'pages.inbounds.stream.general.request' ) } < / Divider >
< Form.Item label = { t ( 'pages.inbounds.stream.tcp.version' ) } >
< Input value = { ib . stream . tcp . request . version }
onChange = { ( e ) = > { ib . stream . tcp . request . version = e . target . value ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = { t ( 'pages.inbounds.stream.tcp.method' ) } >
< Input value = { ib . stream . tcp . request . method }
onChange = { ( e ) = > { ib . stream . tcp . request . method = e . target . value ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = { < > { t ( 'pages.inbounds.stream.tcp.path' ) } < Button size = "small" style = { { marginLeft : 6 } } onClick = { ( ) = > { ib . stream . tcp . request . addPath ( '/' ) ; refresh ( ) ; } } > < PlusOutlined / > < / Button > < / > } >
{ ( ib . stream . tcp . request . path || [ ] ) . map ( ( _p : string , idx : number ) = > (
< Space.Compact key = { ` tcp-path- ${ idx } ` } block className = "mb-4" >
< Input
value = { ib . stream . tcp . request . path [ idx ] }
onChange = { ( e ) = > { ib . stream . tcp . request . path [ idx ] = e . target . value ; refresh ( ) ; } } / >
{ ib . stream . tcp . request . path . length > 1 && (
< Button size = "small" onClick = { ( ) = > { ib . stream . tcp . request . removePath ( idx ) ; refresh ( ) ; } } >
< MinusOutlined / >
< / Button >
) }
< / Space.Compact >
) ) }
< / Form.Item >
< Form.Item label = { t ( 'pages.inbounds.stream.tcp.requestHeader' ) } >
< Button size = "small" onClick = { ( ) = > { ib . stream . tcp . request . addHeader ( 'Host' , '' ) ; refresh ( ) ; } } >
< PlusOutlined / >
< / Button >
< / Form.Item >
{ ( ib . stream . tcp . request . headers || [ ] ) . length > 0 && (
< Form.Item wrapperCol = { { span : 24 } } >
{ ( ib . stream . tcp . request . headers as { name : string ; value : string } [ ] ) . map ( ( h , idx ) = > (
< Space.Compact key = { ` tcp-rh- ${ idx } ` } className = "mb-8" block >
< InputAddon > { String ( idx + 1 ) } < / InputAddon >
< Input value = { h . name }
placeholder = { t ( 'pages.inbounds.stream.general.name' ) }
onChange = { ( e ) = > { h . name = e . target . value ; refresh ( ) ; } } / >
< Input value = { h . value }
placeholder = { t ( 'pages.inbounds.stream.general.value' ) }
onChange = { ( e ) = > { h . value = e . target . value ; refresh ( ) ; } } / >
< Button onClick = { ( ) = > { ib . stream . tcp . request . removeHeader ( idx ) ; refresh ( ) ; } } >
< MinusOutlined / >
< / Button >
< / Space.Compact >
) ) }
< / Form.Item >
) }
< Divider style = { { margin : 0 } } > { t ( 'pages.inbounds.stream.general.response' ) } < / Divider >
< Form.Item label = { t ( 'pages.inbounds.stream.tcp.version' ) } >
< Input value = { ib . stream . tcp . response . version }
onChange = { ( e ) = > { ib . stream . tcp . response . version = e . target . value ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = { t ( 'pages.inbounds.stream.tcp.status' ) } >
< Input value = { ib . stream . tcp . response . status }
onChange = { ( e ) = > { ib . stream . tcp . response . status = e . target . value ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = { t ( 'pages.inbounds.stream.tcp.statusDescription' ) } >
< Input value = { ib . stream . tcp . response . reason }
onChange = { ( e ) = > { ib . stream . tcp . response . reason = e . target . value ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = { t ( 'pages.inbounds.stream.tcp.responseHeader' ) } >
< Button size = "small" onClick = { ( ) = > { ib . stream . tcp . response . addHeader ( 'Content-Type' , 'application/octet-stream' ) ; refresh ( ) ; } } >
< PlusOutlined / >
< / Button >
< / Form.Item >
{ ( ib . stream . tcp . response . headers || [ ] ) . length > 0 && (
< Form.Item wrapperCol = { { span : 24 } } >
{ ( ib . stream . tcp . response . headers as { name : string ; value : string } [ ] ) . map ( ( h , idx ) = > (
< Space.Compact key = { ` tcp-rsh- ${ idx } ` } className = "mb-8" block >
< InputAddon > { String ( idx + 1 ) } < / InputAddon >
< Input value = { h . name }
placeholder = { t ( 'pages.inbounds.stream.general.name' ) }
onChange = { ( e ) = > { h . name = e . target . value ; refresh ( ) ; } } / >
< Input value = { h . value }
placeholder = { t ( 'pages.inbounds.stream.general.value' ) }
onChange = { ( e ) = > { h . value = e . target . value ; refresh ( ) ; } } / >
< Button onClick = { ( ) = > { ib . stream . tcp . response . removeHeader ( idx ) ; refresh ( ) ; } } >
< MinusOutlined / >
< / Button >
< / Space.Compact >
) ) }
< / Form.Item >
) }
< / >
) }
< / >
) }
{ network === 'kcp' && (
< >
< Form.Item label = "MTU" > < InputNumber value = { ib . stream . kcp . mtu } min = { 576 } max = { 1460 } onChange = { ( v ) = > { ib . stream . kcp . mtu = Number ( v ) || 0 ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "TTI (ms)" > < InputNumber value = { ib . stream . kcp . tti } min = { 10 } max = { 100 } onChange = { ( v ) = > { ib . stream . kcp . tti = Number ( v ) || 0 ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "Uplink (MB/s)" > < InputNumber value = { ib . stream . kcp . upCap } min = { 0 } onChange = { ( v ) = > { ib . stream . kcp . upCap = Number ( v ) || 0 ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "Downlink (MB/s)" > < InputNumber value = { ib . stream . kcp . downCap } min = { 0 } onChange = { ( v ) = > { ib . stream . kcp . downCap = Number ( v ) || 0 ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "CWND Multiplier" > < InputNumber value = { ib . stream . kcp . cwndMultiplier } min = { 1 } onChange = { ( v ) = > { ib . stream . kcp . cwndMultiplier = Number ( v ) || 0 ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "Max Sending Window" > < InputNumber value = { ib . stream . kcp . maxSendingWindow } min = { 0 } onChange = { ( v ) = > { ib . stream . kcp . maxSendingWindow = Number ( v ) || 0 ; refresh ( ) ; } } / > < / Form.Item >
< / >
) }
{ network === 'ws' && (
< >
< Form.Item label = "Proxy Protocol" > < Switch checked = { ! ! ib . stream . ws . acceptProxyProtocol } onChange = { ( v ) = > { ib . stream . ws . acceptProxyProtocol = v ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = { t ( 'host' ) } > < Input value = { ib . stream . ws . host } onChange = { ( e ) = > { ib . stream . ws . host = e . target . value ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = { t ( 'path' ) } > < Input value = { ib . stream . ws . path } onChange = { ( e ) = > { ib . stream . ws . path = e . target . value ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "Heartbeat Period" > < InputNumber value = { ib . stream . ws . heartbeatPeriod } min = { 0 } onChange = { ( v ) = > { ib . stream . ws . heartbeatPeriod = Number ( v ) || 0 ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = { t ( 'pages.inbounds.stream.tcp.requestHeader' ) } >
< Button size = "small" onClick = { ( ) = > { ib . stream . ws . addHeader ( '' , '' ) ; refresh ( ) ; } } > < PlusOutlined / > < / Button >
< / Form.Item >
{ ( ib . stream . ws . headers || [ ] ) . length > 0 && (
< Form.Item wrapperCol = { { span : 24 } } >
{ ( ib . stream . ws . headers as { name : string ; value : string } [ ] ) . map ( ( h , idx ) = > (
< Space.Compact key = { ` ws-h- ${ idx } ` } className = "mb-8" block >
< InputAddon > { String ( idx + 1 ) } < / InputAddon >
< Input value = { h . name }
placeholder = { t ( 'pages.inbounds.stream.general.name' ) }
onChange = { ( e ) = > { h . name = e . target . value ; refresh ( ) ; } } / >
< Input value = { h . value }
placeholder = { t ( 'pages.inbounds.stream.general.value' ) }
onChange = { ( e ) = > { h . value = e . target . value ; refresh ( ) ; } } / >
< Button onClick = { ( ) = > { ib . stream . ws . removeHeader ( idx ) ; refresh ( ) ; } } >
< MinusOutlined / >
< / Button >
< / Space.Compact >
) ) }
< / Form.Item >
) }
< / >
) }
{ network === 'grpc' && (
< >
< Form.Item label = "Service Name" > < Input value = { ib . stream . grpc . serviceName } onChange = { ( e ) = > { ib . stream . grpc . serviceName = e . target . value ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "Authority" > < Input value = { ib . stream . grpc . authority } onChange = { ( e ) = > { ib . stream . grpc . authority = e . target . value ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "Multi Mode" > < Switch checked = { ! ! ib . stream . grpc . multiMode } onChange = { ( v ) = > { ib . stream . grpc . multiMode = v ; refresh ( ) ; } } / > < / Form.Item >
< / >
) }
{ network === 'httpupgrade' && (
< >
< Form.Item label = "Proxy Protocol" > < Switch checked = { ! ! ib . stream . httpupgrade . acceptProxyProtocol } onChange = { ( v ) = > { ib . stream . httpupgrade . acceptProxyProtocol = v ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = { t ( 'host' ) } > < Input value = { ib . stream . httpupgrade . host } onChange = { ( e ) = > { ib . stream . httpupgrade . host = e . target . value ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = { t ( 'path' ) } > < Input value = { ib . stream . httpupgrade . path } onChange = { ( e ) = > { ib . stream . httpupgrade . path = e . target . value ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = { t ( 'pages.inbounds.stream.tcp.requestHeader' ) } >
< Button size = "small" onClick = { ( ) = > { ib . stream . httpupgrade . addHeader ( '' , '' ) ; refresh ( ) ; } } > < PlusOutlined / > < / Button >
< / Form.Item >
{ ( ib . stream . httpupgrade . headers || [ ] ) . length > 0 && (
< Form.Item wrapperCol = { { span : 24 } } >
{ ( ib . stream . httpupgrade . headers as { name : string ; value : string } [ ] ) . map ( ( h , idx ) = > (
< Space.Compact key = { ` hu-h- ${ idx } ` } className = "mb-8" block >
< InputAddon > { String ( idx + 1 ) } < / InputAddon >
< Input value = { h . name }
placeholder = { t ( 'pages.inbounds.stream.general.name' ) }
onChange = { ( e ) = > { h . name = e . target . value ; refresh ( ) ; } } / >
< Input value = { h . value }
placeholder = { t ( 'pages.inbounds.stream.general.value' ) }
onChange = { ( e ) = > { h . value = e . target . value ; refresh ( ) ; } } / >
< Button onClick = { ( ) = > { ib . stream . httpupgrade . removeHeader ( idx ) ; refresh ( ) ; } } >
< MinusOutlined / >
< / Button >
< / Space.Compact >
) ) }
< / Form.Item >
) }
< / >
) }
{ network === 'xhttp' && (
< >
< Form.Item label = { t ( 'host' ) } > < Input value = { ib . stream . xhttp . host } onChange = { ( e ) = > { ib . stream . xhttp . host = e . target . value ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = { t ( 'path' ) } > < Input value = { ib . stream . xhttp . path } onChange = { ( e ) = > { ib . stream . xhttp . path = e . target . value ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = { t ( 'pages.inbounds.stream.tcp.requestHeader' ) } >
< Button size = "small" onClick = { ( ) = > { ib . stream . xhttp . addHeader ( '' , '' ) ; refresh ( ) ; } } > < PlusOutlined / > < / Button >
< / Form.Item >
{ ( ib . stream . xhttp . headers || [ ] ) . length > 0 && (
< Form.Item wrapperCol = { { span : 24 } } >
{ ( ib . stream . xhttp . headers as { name : string ; value : string } [ ] ) . map ( ( h , idx ) = > (
< Space.Compact key = { ` xh-h- ${ idx } ` } className = "mb-8" block >
< InputAddon > { String ( idx + 1 ) } < / InputAddon >
< Input value = { h . name }
placeholder = { t ( 'pages.inbounds.stream.general.name' ) }
onChange = { ( e ) = > { h . name = e . target . value ; refresh ( ) ; } } / >
< Input value = { h . value }
placeholder = { t ( 'pages.inbounds.stream.general.value' ) }
onChange = { ( e ) = > { h . value = e . target . value ; refresh ( ) ; } } / >
< Button onClick = { ( ) = > { ib . stream . xhttp . removeHeader ( idx ) ; refresh ( ) ; } } >
< MinusOutlined / >
< / Button >
< / Space.Compact >
) ) }
< / Form.Item >
) }
< Form.Item label = "Mode" >
< Select value = { ib . stream . xhttp . mode } style = { { width : '50%' } } onChange = { ( v ) = > { ib . stream . xhttp . mode = v ; refresh ( ) ; } } >
{ MODE_OPTIONS . map ( ( m ) = > < Select.Option key = { m } value = { m } > { m } < / Select.Option > ) }
< / Select >
< / Form.Item >
{ ib . stream . xhttp . mode === 'packet-up' && (
< >
< Form.Item label = "Max Buffered Upload" > < InputNumber value = { ib . stream . xhttp . scMaxBufferedPosts } onChange = { ( v ) = > { ib . stream . xhttp . scMaxBufferedPosts = Number ( v ) || 0 ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "Max Upload Size (Byte)" > < Input value = { ib . stream . xhttp . scMaxEachPostBytes } onChange = { ( e ) = > { ib . stream . xhttp . scMaxEachPostBytes = e . target . value ; refresh ( ) ; } } / > < / Form.Item >
< / >
) }
{ ib . stream . xhttp . mode === 'stream-up' && (
< Form.Item label = "Stream-Up Server" > < Input value = { ib . stream . xhttp . scStreamUpServerSecs } onChange = { ( e ) = > { ib . stream . xhttp . scStreamUpServerSecs = e . target . value ; refresh ( ) ; } } / > < / Form.Item >
) }
< Form.Item label = "Server Max Header Bytes" > < InputNumber value = { ib . stream . xhttp . serverMaxHeaderBytes } min = { 0 } placeholder = "0 (default)" onChange = { ( v ) = > { ib . stream . xhttp . serverMaxHeaderBytes = Number ( v ) || 0 ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "Padding Bytes" > < Input value = { ib . stream . xhttp . xPaddingBytes } onChange = { ( e ) = > { ib . stream . xhttp . xPaddingBytes = e . target . value ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "Padding Obfs Mode" > < Switch checked = { ! ! ib . stream . xhttp . xPaddingObfsMode } onChange = { ( v ) = > { ib . stream . xhttp . xPaddingObfsMode = v ; refresh ( ) ; } } / > < / Form.Item >
{ ib . stream . xhttp . xPaddingObfsMode && (
< >
< Form.Item label = "Padding Key" > < Input value = { ib . stream . xhttp . xPaddingKey } placeholder = "x_padding" onChange = { ( e ) = > { ib . stream . xhttp . xPaddingKey = e . target . value ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "Padding Header" > < Input value = { ib . stream . xhttp . xPaddingHeader } placeholder = "X-Padding" onChange = { ( e ) = > { ib . stream . xhttp . xPaddingHeader = e . target . value ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "Padding Placement" >
< Select value = { ib . stream . xhttp . xPaddingPlacement } onChange = { ( v ) = > { ib . stream . xhttp . xPaddingPlacement = v ; refresh ( ) ; } } >
< Select.Option value = "" > Default ( queryInHeader ) < / Select.Option >
< Select.Option value = "queryInHeader" > queryInHeader < / Select.Option >
< Select.Option value = "header" > header < / Select.Option >
< Select.Option value = "cookie" > cookie < / Select.Option >
< Select.Option value = "query" > query < / Select.Option >
< / Select >
< / Form.Item >
< Form.Item label = "Padding Method" >
< Select value = { ib . stream . xhttp . xPaddingMethod } onChange = { ( v ) = > { ib . stream . xhttp . xPaddingMethod = v ; refresh ( ) ; } } >
< Select.Option value = "" > Default ( repeat - x ) < / Select.Option >
< Select.Option value = "repeat-x" > repeat - x < / Select.Option >
< Select.Option value = "tokenish" > tokenish < / Select.Option >
< / Select >
< / Form.Item >
< / >
) }
< Form.Item label = "Session Placement" >
< Select value = { ib . stream . xhttp . sessionPlacement } onChange = { ( v ) = > { ib . stream . xhttp . sessionPlacement = v ; refresh ( ) ; } } >
< Select.Option value = "" > Default ( path ) < / Select.Option >
< Select.Option value = "path" > path < / Select.Option >
< Select.Option value = "header" > header < / Select.Option >
< Select.Option value = "cookie" > cookie < / Select.Option >
< Select.Option value = "query" > query < / Select.Option >
< / Select >
< / Form.Item >
{ ib . stream . xhttp . sessionPlacement && ib . stream . xhttp . sessionPlacement !== 'path' && (
< Form.Item label = "Session Key" > < Input value = { ib . stream . xhttp . sessionKey } placeholder = "x_session" onChange = { ( e ) = > { ib . stream . xhttp . sessionKey = e . target . value ; refresh ( ) ; } } / > < / Form.Item >
) }
< Form.Item label = "Sequence Placement" >
< Select value = { ib . stream . xhttp . seqPlacement } onChange = { ( v ) = > { ib . stream . xhttp . seqPlacement = v ; refresh ( ) ; } } >
< Select.Option value = "" > Default ( path ) < / Select.Option >
< Select.Option value = "path" > path < / Select.Option >
< Select.Option value = "header" > header < / Select.Option >
< Select.Option value = "cookie" > cookie < / Select.Option >
< Select.Option value = "query" > query < / Select.Option >
< / Select >
< / Form.Item >
{ ib . stream . xhttp . seqPlacement && ib . stream . xhttp . seqPlacement !== 'path' && (
< Form.Item label = "Sequence Key" > < Input value = { ib . stream . xhttp . seqKey } placeholder = "x_seq" onChange = { ( e ) = > { ib . stream . xhttp . seqKey = e . target . value ; refresh ( ) ; } } / > < / Form.Item >
) }
{ ib . stream . xhttp . mode === 'packet-up' && (
< Form.Item label = "Uplink Data Placement" >
< Select value = { ib . stream . xhttp . uplinkDataPlacement } onChange = { ( v ) = > { ib . stream . xhttp . uplinkDataPlacement = v ; refresh ( ) ; } } >
< Select.Option value = "" > Default ( body ) < / Select.Option >
< Select.Option value = "body" > body < / Select.Option >
< Select.Option value = "header" > header < / Select.Option >
< Select.Option value = "cookie" > cookie < / Select.Option >
< Select.Option value = "query" > query < / Select.Option >
< / Select >
< / Form.Item >
) }
{ ib . stream . xhttp . mode === 'packet-up' && ib . stream . xhttp . uplinkDataPlacement && ib . stream . xhttp . uplinkDataPlacement !== 'body' && (
< Form.Item label = "Uplink Data Key" > < Input value = { ib . stream . xhttp . uplinkDataKey } placeholder = "x_data" onChange = { ( e ) = > { ib . stream . xhttp . uplinkDataKey = e . target . value ; refresh ( ) ; } } / > < / Form.Item >
) }
< Form.Item label = "No SSE Header" > < Switch checked = { ! ! ib . stream . xhttp . noSSEHeader } onChange = { ( v ) = > { ib . stream . xhttp . noSSEHeader = v ; refresh ( ) ; } } / > < / Form.Item >
< / >
) }
< Form.Item label = "External Proxy" >
< Switch checked = { externalProxyOn } onChange = { setExternalProxy } / >
{ externalProxyOn && (
< Button size = "small" type = "primary" style = { { marginLeft : 10 } }
onClick = { ( ) = > { ib . stream . externalProxy . push ( { forceTls : 'same' , dest : '' , port : 443 , remark : '' } ) ; refresh ( ) ; } } >
< PlusOutlined / >
< / Button >
) }
< / Form.Item >
{ externalProxyOn && (
< Form.Item wrapperCol = { { span : 24 } } >
{ ( ib . stream . externalProxy as { forceTls : string ; dest : string ; port : number ; remark : string } [ ] ) . map ( ( row , idx ) = > (
< Space.Compact key = { ` ep- ${ idx } ` } style = { { margin : '8px 0' } } block >
< Tooltip title = "Force TLS" >
< Select value = { row . forceTls } style = { { width : '20%' } } onChange = { ( v ) = > { row . forceTls = v ; refresh ( ) ; } } >
< Select.Option value = "same" > { t ( 'pages.inbounds.same' ) } < / Select.Option >
< Select.Option value = "none" > { t ( 'none' ) } < / Select.Option >
< Select.Option value = "tls" > TLS < / Select.Option >
< / Select >
< / Tooltip >
< Input style = { { width : '30%' } } value = { row . dest } placeholder = { t ( 'host' ) }
onChange = { ( e ) = > { row . dest = e . target . value ; refresh ( ) ; } } / >
< Tooltip title = { t ( 'pages.inbounds.port' ) } >
< InputNumber value = { row . port } style = { { width : '15%' } } min = { 1 } max = { 65535 }
onChange = { ( v ) = > { row . port = Number ( v ) || 0 ; refresh ( ) ; } } / >
< / Tooltip >
< Input style = { { width : '25%' } } value = { row . remark } placeholder = { t ( 'pages.inbounds.remark' ) }
onChange = { ( e ) = > { row . remark = e . target . value ; refresh ( ) ; } } / >
< InputAddon onClick = { ( ) = > { ib . stream . externalProxy . splice ( idx , 1 ) ; refresh ( ) ; } } >
< MinusOutlined / >
< / InputAddon >
< / Space.Compact >
) ) }
< / Form.Item >
) }
< Form.Item label = "Sockopt" > < Switch checked = { ! ! ib . stream . sockoptSwitch } onChange = { ( v ) = > { ib . stream . sockoptSwitch = v ; refresh ( ) ; } } / > < / Form.Item >
{ ib . stream . sockoptSwitch && ib . stream . sockopt && (
< >
< Form.Item label = "Route Mark" > < InputNumber value = { ib . stream . sockopt . mark } min = { 0 } onChange = { ( v ) = > { ib . stream . sockopt . mark = Number ( v ) || 0 ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "TCP Keep Alive Interval" > < InputNumber value = { ib . stream . sockopt . tcpKeepAliveInterval } min = { 0 } onChange = { ( v ) = > { ib . stream . sockopt . tcpKeepAliveInterval = Number ( v ) || 0 ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "TCP Keep Alive Idle" > < InputNumber value = { ib . stream . sockopt . tcpKeepAliveIdle } min = { 0 } onChange = { ( v ) = > { ib . stream . sockopt . tcpKeepAliveIdle = Number ( v ) || 0 ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "TCP Max Seg" > < InputNumber value = { ib . stream . sockopt . tcpMaxSeg } min = { 0 } onChange = { ( v ) = > { ib . stream . sockopt . tcpMaxSeg = Number ( v ) || 0 ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "TCP User Timeout" > < InputNumber value = { ib . stream . sockopt . tcpUserTimeout } min = { 0 } onChange = { ( v ) = > { ib . stream . sockopt . tcpUserTimeout = Number ( v ) || 0 ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "TCP Window Clamp" > < InputNumber value = { ib . stream . sockopt . tcpWindowClamp } min = { 0 } onChange = { ( v ) = > { ib . stream . sockopt . tcpWindowClamp = Number ( v ) || 0 ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "Proxy Protocol" > < Switch checked = { ! ! ib . stream . sockopt . acceptProxyProtocol } onChange = { ( v ) = > { ib . stream . sockopt . acceptProxyProtocol = v ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "TCP Fast Open" > < Switch checked = { ! ! ib . stream . sockopt . tcpFastOpen } onChange = { ( v ) = > { ib . stream . sockopt . tcpFastOpen = v ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "Multipath TCP" > < Switch checked = { ! ! ib . stream . sockopt . tcpMptcp } onChange = { ( v ) = > { ib . stream . sockopt . tcpMptcp = v ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "Penetrate" > < Switch checked = { ! ! ib . stream . sockopt . penetrate } onChange = { ( v ) = > { ib . stream . sockopt . penetrate = v ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "V6 Only" > < Switch checked = { ! ! ib . stream . sockopt . V6Only } onChange = { ( v ) = > { ib . stream . sockopt . V6Only = v ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "Domain Strategy" >
< Select value = { ib . stream . sockopt . domainStrategy } style = { { width : '50%' } } onChange = { ( v ) = > { ib . stream . sockopt . domainStrategy = v ; refresh ( ) ; } } >
{ DOMAIN_STRATEGIES . map ( ( d ) = > < Select.Option key = { d } value = { d } > { d } < / Select.Option > ) }
< / Select >
< / Form.Item >
< Form.Item label = "TCP Congestion" >
< Select value = { ib . stream . sockopt . tcpcongestion } style = { { width : '50%' } } onChange = { ( v ) = > { ib . stream . sockopt . tcpcongestion = v ; refresh ( ) ; } } >
{ TCP_CONGESTIONS . map ( ( c ) = > < Select.Option key = { c } value = { c } > { c } < / Select.Option > ) }
< / Select >
< / Form.Item >
< Form.Item label = "TProxy" >
< Select value = { ib . stream . sockopt . tproxy } style = { { width : '50%' } } onChange = { ( v ) = > { ib . stream . sockopt . tproxy = v ; refresh ( ) ; } } >
< Select.Option value = "off" > Off < / Select.Option >
< Select.Option value = "redirect" > Redirect < / Select.Option >
< Select.Option value = "tproxy" > TProxy < / Select.Option >
< / Select >
< / Form.Item >
< Form.Item label = "Dialer Proxy" > < Input value = { ib . stream . sockopt . dialerProxy } onChange = { ( e ) = > { ib . stream . sockopt . dialerProxy = e . target . value ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "Interface Name" > < Input value = { ib . stream . sockopt . interfaceName } onChange = { ( e ) = > { ib . stream . sockopt . interfaceName = e . target . value ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "Trusted X-Forwarded-For" >
< Select mode = "tags" value = { ib . stream . sockopt . trustedXForwardedFor } style = { { width : '100%' } }
tokenSeparators = { [ ',' ] }
onChange = { ( v ) = > { ib . stream . sockopt . trustedXForwardedFor = v ; refresh ( ) ; } } >
< Select.Option value = "CF-Connecting-IP" > CF - Connecting - IP < / Select.Option >
< Select.Option value = "X-Real-IP" > X - Real - IP < / Select.Option >
< Select.Option value = "True-Client-IP" > True - Client - IP < / Select.Option >
< Select.Option value = "X-Client-IP" > X - Client - IP < / Select.Option >
< / Select >
< / Form.Item >
< / >
) }
{ ib . protocol === Protocols . HYSTERIA && (
< >
< Form.Item label = { < Tooltip title = "Hysteria protocol version. Currently must be 2." > Version < / Tooltip > } >
< InputNumber value = { ib . stream . hysteria . version } min = { 2 } max = { 2 } onChange = { ( v ) = > { ib . stream . hysteria . version = Number ( v ) || 2 ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = { < Tooltip title = "Idle timeout (seconds) for a single QUIC native UDP connection." > UDP idle timeout < / Tooltip > } >
< InputNumber value = { ib . stream . hysteria . udpIdleTimeout } min = { 0 } onChange = { ( v ) = > { ib . stream . hysteria . udpIdleTimeout = Number ( v ) || 0 ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = "Masquerade" >
< Switch checked = { ! ! ib . stream . hysteria . masqueradeSwitch } onChange = { ( v ) = > { ib . stream . hysteria . masqueradeSwitch = v ; refresh ( ) ; } } / >
< / Form.Item >
{ ib . stream . hysteria . masqueradeSwitch && (
< >
< Form.Item label = "Type" >
< Select value = { ib . stream . hysteria . masquerade . type } style = { { width : '50%' } } onChange = { ( v ) = > { ib . stream . hysteria . masquerade . type = v ; refresh ( ) ; } } >
< Select.Option value = "proxy" > Proxy < / Select.Option >
< Select.Option value = "file" > File < / Select.Option >
< Select.Option value = "string" > String < / Select.Option >
< / Select >
< / Form.Item >
{ ib . stream . hysteria . masquerade . type === 'proxy' && (
< >
< Form.Item label = "URL" > < Input value = { ib . stream . hysteria . masquerade . url } placeholder = "https://example.com" onChange = { ( e ) = > { ib . stream . hysteria . masquerade . url = e . target . value ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "Rewrite Host" > < Switch checked = { ! ! ib . stream . hysteria . masquerade . rewriteHost } onChange = { ( v ) = > { ib . stream . hysteria . masquerade . rewriteHost = v ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "Insecure" > < Switch checked = { ! ! ib . stream . hysteria . masquerade . insecure } onChange = { ( v ) = > { ib . stream . hysteria . masquerade . insecure = v ; refresh ( ) ; } } / > < / Form.Item >
< / >
) }
{ ib . stream . hysteria . masquerade . type === 'file' && (
< Form.Item label = "Directory" > < Input value = { ib . stream . hysteria . masquerade . dir } placeholder = "/path/to/www" onChange = { ( e ) = > { ib . stream . hysteria . masquerade . dir = e . target . value ; refresh ( ) ; } } / > < / Form.Item >
) }
{ ib . stream . hysteria . masquerade . type === 'string' && (
< >
< Form.Item label = "Content" > < TextArea value = { ib . stream . hysteria . masquerade . content } autoSize = { { minRows : 2 , maxRows : 6 } } onChange = { ( e ) = > { ib . stream . hysteria . masquerade . content = e . target . value ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "Status Code" > < InputNumber value = { ib . stream . hysteria . masquerade . statusCode } min = { 100 } max = { 599 } placeholder = "200" onChange = { ( v ) = > { ib . stream . hysteria . masquerade . statusCode = Number ( v ) || 0 ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "Headers" >
< Button size = "small" onClick = { ( ) = > { ib . stream . hysteria . masquerade . addHeader ( '' , '' ) ; refresh ( ) ; } } >
< PlusOutlined / >
< / Button >
< / Form.Item >
{ ( ib . stream . hysteria . masquerade . headers || [ ] ) . length > 0 && (
< Form.Item wrapperCol = { { span : 24 } } >
{ ( ib . stream . hysteria . masquerade . headers as { name : string ; value : string } [ ] ) . map ( ( h , idx ) = > (
< Space.Compact key = { ` mh- ${ idx } ` } className = "mb-8" block >
< InputAddon > { String ( idx + 1 ) } < / InputAddon >
< Input value = { h . name } placeholder = "Name"
onChange = { ( e ) = > { h . name = e . target . value ; refresh ( ) ; } } / >
< Input value = { h . value } placeholder = "Value"
onChange = { ( e ) = > { h . value = e . target . value ; refresh ( ) ; } } / >
< Button onClick = { ( ) = > { ib . stream . hysteria . masquerade . removeHeader ( idx ) ; refresh ( ) ; } } >
< MinusOutlined / >
< / Button >
< / Space.Compact >
) ) }
< / Form.Item >
) }
< / >
) }
< / >
) }
< / >
) }
< / Form >
< FinalMaskForm stream = { ib . stream } protocol = { ib . protocol } onChange = { refresh } / >
< / >
) ;
} ;
const renderSecurityTab = ( ) = > (
< Form colon = { false } labelCol = { { sm : { span : 8 } } } wrapperCol = { { sm : { span : 14 } } } >
< Form.Item label = { t ( 'pages.inbounds.securityTab' ) } >
< Radio.Group value = { ib . stream . security } buttonStyle = "solid" disabled = { ! canEnableTls }
onChange = { ( e ) = > setSecurity ( e . target . value ) } >
< Radio.Button value = "none" > none < / Radio.Button >
< Radio.Button value = "tls" > tls < / Radio.Button >
{ canEnableReality && < Radio.Button value = "reality" > reality < / Radio.Button > }
< / Radio.Group >
< / Form.Item >
{ ib . stream . security === 'tls' && ib . stream . tls && (
< >
< Form.Item label = "SNI" > < Input value = { ib . stream . tls . sni } placeholder = "Server Name Indication" onChange = { ( e ) = > { ib . stream . tls . sni = e . target . value ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "Cipher Suites" >
< Select value = { ib . stream . tls . cipherSuites } onChange = { ( v ) = > { ib . stream . tls . cipherSuites = v ; refresh ( ) ; } } >
< Select.Option value = "" > Auto < / Select.Option >
{ CIPHER_SUITES . map ( ( [ label , val ] ) = > < Select.Option key = { val } value = { val } > { label } < / Select.Option > ) }
< / Select >
< / Form.Item >
< Form.Item label = "Min/Max Version" >
< Space.Compact block >
< Select value = { ib . stream . tls . minVersion } style = { { width : '50%' } } onChange = { ( v ) = > { ib . stream . tls . minVersion = v ; refresh ( ) ; } } >
{ TLS_VERSIONS . map ( ( v ) = > < Select.Option key = { v } value = { v } > { v } < / Select.Option > ) }
< / Select >
< Select value = { ib . stream . tls . maxVersion } style = { { width : '50%' } } onChange = { ( v ) = > { ib . stream . tls . maxVersion = v ; refresh ( ) ; } } >
{ TLS_VERSIONS . map ( ( v ) = > < Select.Option key = { v } value = { v } > { v } < / Select.Option > ) }
< / Select >
< / Space.Compact >
< / Form.Item >
< Form.Item label = "uTLS" >
< Select value = { ib . stream . tls . settings . fingerprint } style = { { width : '100%' } } onChange = { ( v ) = > { ib . stream . tls . settings . fingerprint = v ; refresh ( ) ; } } >
< Select.Option value = "" > None < / Select.Option >
{ FINGERPRINTS . map ( ( fp ) = > < Select.Option key = { fp } value = { fp } > { fp } < / Select.Option > ) }
< / Select >
< / Form.Item >
< Form.Item label = "ALPN" >
< Select mode = "multiple" value = { ib . stream . tls . alpn } style = { { width : '100%' } } tokenSeparators = { [ ',' ] }
onChange = { ( v ) = > { ib . stream . tls . alpn = v ; refresh ( ) ; } } >
{ ALPNS . map ( ( a ) = > < Select.Option key = { a } value = { a } > { a } < / Select.Option > ) }
< / Select >
< / Form.Item >
< Form.Item label = "Reject Unknown SNI" > < Switch checked = { ! ! ib . stream . tls . rejectUnknownSni } onChange = { ( v ) = > { ib . stream . tls . rejectUnknownSni = v ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "Disable System Root" > < Switch checked = { ! ! ib . stream . tls . disableSystemRoot } onChange = { ( v ) = > { ib . stream . tls . disableSystemRoot = v ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "Session Resumption" > < Switch checked = { ! ! ib . stream . tls . enableSessionResumption } onChange = { ( v ) = > { ib . stream . tls . enableSessionResumption = v ; refresh ( ) ; } } / > < / Form.Item >
{ ( ib . stream . tls . certs || [ ] ) . map ( ( cert : any , idx : number ) = > (
< div key = { ` cert- ${ idx } ` } >
< Form.Item label = { t ( 'certificate' ) } >
< Radio.Group value = { cert . useFile } buttonStyle = "solid" onChange = { ( e ) = > { cert . useFile = e . target . value ; refresh ( ) ; } } >
< Radio.Button value = { true } > { t ( 'pages.inbounds.certificatePath' ) } < / Radio.Button >
< Radio.Button value = { false } > { t ( 'pages.inbounds.certificateContent' ) } < / Radio.Button >
< / Radio.Group >
< / Form.Item >
< Form.Item label = " " >
< Space >
{ idx === 0 && (
< Button type = "primary" size = "small" onClick = { ( ) = > { ib . stream . tls . addCert ( ) ; refresh ( ) ; } } >
< PlusOutlined / >
< / Button >
) }
{ ib . stream . tls . certs . length > 1 && (
< Button type = "primary" size = "small" onClick = { ( ) = > { ib . stream . tls . removeCert ( idx ) ; refresh ( ) ; } } >
< MinusOutlined / >
< / Button >
) }
< / Space >
< / Form.Item >
{ cert . useFile ? (
< >
< Form.Item label = { t ( 'pages.inbounds.publicKey' ) } >
< Input value = { cert . certFile } onChange = { ( e ) = > { cert . certFile = e . target . value ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = { t ( 'pages.inbounds.privatekey' ) } >
< Input value = { cert . keyFile } onChange = { ( e ) = > { cert . keyFile = e . target . value ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = " " >
< Button type = "primary" disabled = { ! defaultCert && ! defaultKey } onClick = { ( ) = > setDefaultCertData ( idx ) } >
{ t ( 'pages.inbounds.setDefaultCert' ) }
< / Button >
< / Form.Item >
< / >
) : (
< >
< Form.Item label = { t ( 'pages.inbounds.publicKey' ) } >
< TextArea value = { cert . cert } autoSize = { { minRows : 3 , maxRows : 8 } }
onChange = { ( e ) = > { cert . cert = e . target . value ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = { t ( 'pages.inbounds.privatekey' ) } >
< TextArea value = { cert . key } autoSize = { { minRows : 3 , maxRows : 8 } }
onChange = { ( e ) = > { cert . key = e . target . value ; refresh ( ) ; } } / >
< / Form.Item >
< / >
) }
< Form.Item label = "One Time Loading" > < Switch checked = { ! ! cert . oneTimeLoading } onChange = { ( v ) = > { cert . oneTimeLoading = v ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "Usage Option" >
< Select value = { cert . usage } style = { { width : '50%' } } onChange = { ( v ) = > { cert . usage = v ; refresh ( ) ; } } >
{ USAGES . map ( ( u ) = > < Select.Option key = { u } value = { u } > { u } < / Select.Option > ) }
< / Select >
< / Form.Item >
{ cert . usage === 'issue' && (
< Form.Item label = "Build Chain" > < Switch checked = { ! ! cert . buildChain } onChange = { ( v ) = > { cert . buildChain = v ; refresh ( ) ; } } / > < / Form.Item >
) }
< / div >
) ) }
< Form.Item label = "ECH key" > < Input value = { ib . stream . tls . echServerKeys } onChange = { ( e ) = > { ib . stream . tls . echServerKeys = e . target . value ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "ECH config" > < Input value = { ib . stream . tls . settings . echConfigList } onChange = { ( e ) = > { ib . stream . tls . settings . echConfigList = e . target . value ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = " " >
< Space >
< Button type = "primary" loading = { saving } onClick = { getNewEchCert } > Get New ECH Cert < / Button >
< Button danger onClick = { clearEchCert } > Clear < / Button >
< / Space >
< / Form.Item >
< / >
) }
{ ib . stream . security === 'reality' && ib . stream . reality && (
< >
< Form.Item label = "Show" > < Switch checked = { ! ! ib . stream . reality . show } onChange = { ( v ) = > { ib . stream . reality . show = v ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "Xver" > < InputNumber value = { ib . stream . reality . xver } min = { 0 } onChange = { ( v ) = > { ib . stream . reality . xver = Number ( v ) || 0 ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "uTLS" >
< Select value = { ib . stream . reality . settings . fingerprint } style = { { width : '100%' } } onChange = { ( v ) = > { ib . stream . reality . settings . fingerprint = v ; refresh ( ) ; } } >
{ FINGERPRINTS . map ( ( fp ) = > < Select.Option key = { fp } value = { fp } > { fp } < / Select.Option > ) }
< / Select >
< / Form.Item >
< Form.Item label = { < > Target < SyncOutlined className = "random-icon" onClick = { randomizeRealityTarget } / > < / > } >
< Input value = { ib . stream . reality . target } onChange = { ( e ) = > { ib . stream . reality . target = e . target . value ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = { < > SNI < SyncOutlined className = "random-icon" onClick = { randomizeRealityTarget } / > < / > } >
< Input value = { ib . stream . reality . serverNames } onChange = { ( e ) = > { ib . stream . reality . serverNames = e . target . value ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = "Max Time Diff (ms)" > < InputNumber value = { ib . stream . reality . maxTimediff } min = { 0 } onChange = { ( v ) = > { ib . stream . reality . maxTimediff = Number ( v ) || 0 ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "Min Client Ver" > < Input value = { ib . stream . reality . minClientVer } placeholder = "25.9.11" onChange = { ( e ) = > { ib . stream . reality . minClientVer = e . target . value ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = "Max Client Ver" > < Input value = { ib . stream . reality . maxClientVer } placeholder = "25.9.11" onChange = { ( e ) = > { ib . stream . reality . maxClientVer = e . target . value ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = { < > Short IDs < SyncOutlined className = "random-icon" onClick = { randomizeShortIds } / > < / > } >
< TextArea value = { ib . stream . reality . shortIds } autoSize = { { minRows : 1 , maxRows : 4 } } onChange = { ( e ) = > { ib . stream . reality . shortIds = e . target . value ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = "SpiderX" > < Input value = { ib . stream . reality . settings . spiderX } onChange = { ( e ) = > { ib . stream . reality . settings . spiderX = e . target . value ; refresh ( ) ; } } / > < / Form.Item >
< Form.Item label = { t ( 'pages.inbounds.publicKey' ) } >
< TextArea value = { ib . stream . reality . settings . publicKey } autoSize = { { minRows : 1 , maxRows : 4 } }
onChange = { ( e ) = > { ib . stream . reality . settings . publicKey = e . target . value ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = { t ( 'pages.inbounds.privatekey' ) } >
< TextArea value = { ib . stream . reality . privateKey } autoSize = { { minRows : 1 , maxRows : 4 } }
onChange = { ( e ) = > { ib . stream . reality . privateKey = e . target . value ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = " " >
< Space >
< Button type = "primary" loading = { saving } onClick = { genRealityKeypair } > Get New Cert < / Button >
< Button danger onClick = { clearRealityKeypair } > Clear < / Button >
< / Space >
< / Form.Item >
< Form.Item label = "mldsa65 Seed" >
< TextArea value = { ib . stream . reality . mldsa65Seed } autoSize = { { minRows : 2 , maxRows : 6 } } onChange = { ( e ) = > { ib . stream . reality . mldsa65Seed = e . target . value ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = "mldsa65 Verify" >
< TextArea value = { ib . stream . reality . settings . mldsa65Verify } autoSize = { { minRows : 2 , maxRows : 6 } } onChange = { ( e ) = > { ib . stream . reality . settings . mldsa65Verify = e . target . value ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = " " >
< Space >
< Button type = "primary" loading = { saving } onClick = { genMldsa65 } > Get New Seed < / Button >
< Button danger onClick = { clearMldsa65 } > Clear < / Button >
< / Space >
< / Form.Item >
< / >
) }
< / Form >
) ;
const renderSniffingTab = ( ) = > (
< Form colon = { false } labelCol = { { sm : { span : 8 } } } wrapperCol = { { sm : { span : 14 } } } >
< Form.Item label = { t ( 'enable' ) } >
< Switch checked = { ! ! ib . sniffing . enabled } onChange = { ( v ) = > { ib . sniffing . enabled = v ; refresh ( ) ; } } / >
< / Form.Item >
{ ib . sniffing . enabled && (
< >
< Form.Item wrapperCol = { { span : 24 } } >
< Checkbox.Group value = { ib . sniffing . destOverride } onChange = { ( v ) = > { ib . sniffing . destOverride = v ; refresh ( ) ; } } >
{ Object . entries ( SNIFFING_OPTION ) . map ( ( [ key , value ] ) = > (
< Checkbox key = { key } value = { value } > { key } < / Checkbox >
) ) }
< / Checkbox.Group >
< / Form.Item >
< Form.Item label = { t ( 'pages.inbounds.sniffingMetadataOnly' ) } >
< Switch checked = { ! ! ib . sniffing . metadataOnly } onChange = { ( v ) = > { ib . sniffing . metadataOnly = v ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = { t ( 'pages.inbounds.sniffingRouteOnly' ) } >
< Switch checked = { ! ! ib . sniffing . routeOnly } onChange = { ( v ) = > { ib . sniffing . routeOnly = v ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = { t ( 'pages.inbounds.sniffingIpsExcluded' ) } >
< Select mode = "tags" value = { ib . sniffing . ipsExcluded } tokenSeparators = { [ ',' ] }
placeholder = "IP/CIDR/geoip:*/ext:*" style = { { width : '100%' } }
onChange = { ( v ) = > { ib . sniffing . ipsExcluded = v ; refresh ( ) ; } } / >
< / Form.Item >
< Form.Item label = { t ( 'pages.inbounds.sniffingDomainsExcluded' ) } >
< Select mode = "tags" value = { ib . sniffing . domainsExcluded } tokenSeparators = { [ ',' ] }
placeholder = "domain:*/ext:*" style = { { width : '100%' } }
onChange = { ( v ) = > { ib . sniffing . domainsExcluded = v ; refresh ( ) ; } } / >
< / Form.Item >
< / >
) }
< / Form >
) ;
const renderAdvancedTab = ( ) = > {
const advancedTabItems = [
{
key : 'all' ,
label : t ( 'pages.inbounds.advanced.all' ) ,
children : (
< >
< div className = "advanced-editor-meta" > { t ( 'pages.inbounds.advanced.allHelp' ) } < / div >
< JsonEditor value = { advancedAllValue } onChange = { setAdvancedAllValue } minHeight = "340px" maxHeight = "560px" / >
< / >
) ,
} ,
{
key : 'settings' ,
label : t ( 'pages.inbounds.advanced.settings' ) ,
children : (
< >
< div className = "advanced-editor-meta" >
{ t ( 'pages.inbounds.advanced.settingsHelp' ) } < code > { '{ settings: { ... } }' } < / code > .
< / div >
< JsonEditor value = { wrappedConfigValue ( 'settings' , 'settings' ) }
onChange = { ( v ) = > setWrappedConfigValue ( 'settings' , 'settings' , 'Settings' , v ) }
minHeight = "320px" maxHeight = "540px" / >
< / >
) ,
} ,
{
key : 'sniffingSection' ,
label : t ( 'pages.inbounds.advanced.sniffing' ) ,
children : (
< >
< div className = "advanced-editor-meta" >
{ t ( 'pages.inbounds.advanced.sniffingHelp' ) } < code > { '{ sniffing: { ... } }' } < / code > .
< / div >
< JsonEditor value = { wrappedConfigValue ( 'sniffing' , 'sniffing' ) }
onChange = { ( v ) = > setWrappedConfigValue ( 'sniffing' , 'sniffing' , 'Sniffing' , v ) }
minHeight = "240px" maxHeight = "420px" / >
< / >
) ,
} ,
] ;
if ( canEnableStream ) {
advancedTabItems . push ( {
key : 'streamSection' ,
label : t ( 'pages.inbounds.advanced.stream' ) ,
children : (
< >
< div className = "advanced-editor-meta" >
{ t ( 'pages.inbounds.advanced.streamHelp' ) } < code > { '{ streamSettings: { ... } }' } < / code > .
< / div >
< JsonEditor value = { wrappedConfigValue ( 'streamSettings' , 'stream' ) }
onChange = { ( v ) = > setWrappedConfigValue ( 'streamSettings' , 'stream' , 'Stream' , v ) }
minHeight = "320px" maxHeight = "540px" / >
< / >
) ,
} ) ;
}
return (
< div className = "advanced-shell" >
< div className = "advanced-panel" >
< div className = "advanced-panel__header" >
< div >
< div className = "advanced-panel__title" > { t ( 'pages.inbounds.advanced.title' ) } < / div >
< div className = "advanced-panel__subtitle" > { t ( 'pages.inbounds.advanced.subtitle' ) } < / div >
< / div >
< / div >
< Tabs activeKey = { advancedSectionKey } onChange = { setAdvancedSectionKey } items = { advancedTabItems } className = "advanced-inner-tabs" / >
< / div >
< / div >
) ;
} ;
const tabItems = [
{ key : 'basic' , label : t ( 'pages.xray.basicTemplate' ) , children : renderBasicsTab ( ) } ,
] ;
if ( hasProtocolTabContent ) {
tabItems . push ( { key : 'protocol' , label : t ( 'pages.inbounds.protocol' ) , children : renderProtocolTab ( ) } ) ;
}
if ( canEnableStream ) {
tabItems . push ( { key : 'stream' , label : t ( 'pages.inbounds.streamTab' ) , children : renderStreamTab ( ) } ) ;
tabItems . push ( { key : 'security' , label : t ( 'pages.inbounds.securityTab' ) , children : renderSecurityTab ( ) } ) ;
}
tabItems . push ( { key : 'sniffing' , label : t ( 'pages.inbounds.sniffingTab' ) , children : renderSniffingTab ( ) } ) ;
tabItems . push ( { key : 'advanced' , label : t ( 'pages.xray.advancedTemplate' ) , children : renderAdvancedTab ( ) } ) ;
return (
< >
{ messageContextHolder }
< Modal
open = { open }
title = { title }
okText = { okText }
cancelText = { t ( 'close' ) }
confirmLoading = { saving }
mask = { { closable : false } }
width = { 780 }
onOk = { submit }
onCancel = { onClose }
destroyOnHidden
>
< Tabs activeKey = { activeTabKey } onChange = { handleTabChange } items = { tabItems } / >
< / Modal >
< / >
) ;
}