Files
3x-ui/frontend/src/pages/xray/OutboundFormModal.tsx
T
Sanaei dc37f9b731 Migrate frontend models/api/utils to TypeScript and modernize AntD theming (#4563)
* refactor(frontend): port api/* and reality-targets to TypeScript

Phase 1 of the JS→TS migration: convert three small, isolated files
(axios-init, websocket, reality-targets) to typed sources so future
phases can lean on their interfaces.

- api/axios-init.ts: typed CSRF cache, interceptors, request retry
- api/websocket.ts: typed listener map, message envelope guard,
  reconnect timer
- models/reality-targets.ts: RealityTarget interface, readonly list
- env.d.ts: minimal qs module shim (stringify/parse)
- consumers: drop ".js" extension from @/api imports

* refactor(frontend): port utils/index to TypeScript

Phase 2 of the JS→TS migration: convert the 858-line utility module
that 30+ pages and hooks depend on.

- Msg<T = any> generic with success/msg/obj shape preserved
- HttpUtil get/post/postWithModal generic over response shape
- RandomUtil, Wireguard, Base64 fully typed
- SizeFormatter/CPUFormatter/TimeFormatter/NumberFormatter typed
- ColorUtils.usageColor returns 'green'|'orange'|'red'|'purple' union
- LanguageManager.supportedLanguages readonly typed
- IntlUtil.formatDate/formatRelativeTime accept null/undefined
- ObjectUtil.clone/deepClone/cloneProps/equals kept as `any`-shaped
  to preserve the prior JS contract used by class-instance callers
  (AllSetting.cloneProps(this, data), etc.)

* refactor(frontend): port models/outbound to TypeScript (hybrid typing)

Phase 4 of the JS→TS migration: rename outbound.js to outbound.ts and
make it compile under strict mode with a minimal hybrid type pass.

- Enum-like constants kept as typed objects (Protocols, SSMethods, …)
- Top-level DNS helpers strictly typed
- CommonClass gets [key: string]: any so all subclasses can keep their
  loose this.foo = bar assignments without per-field declarations
- Constructor / fromJson / toJson signatures typed as any to preserve
  the prior JS contract used by consumers and parsers
- Outbound declares static fields for the dynamically-attached Settings
  subclasses (Settings, FreedomSettings, VmessSettings, …)
- urlParams.get() results that feed parseInt now use the non-null
  assertion since the surrounding has() check already guards them
- File-level eslint-disable for no-explicit-any/no-var/prefer-const to
  keep the JS-derived code building without churn

* refactor(frontend): port models/inbound to TypeScript (hybrid typing)

Phase 5 of the JS→TS migration. Same hybrid approach as outbound.ts:
constants typed strictly, classes get [key: string]: any from
XrayCommonClass, constructor / fromJson / toJson signatures use any.

- XrayCommonClass gains [key: string]: any plus typed static helpers
  (toJsonArray, fallbackToJson, toHeaders, toV2Headers)
- TcpStreamSettings/TlsStreamSettings/RealityStreamSettings/Inbound
  declare static fields for their dynamically-attached subclasses
  (TcpRequest, TcpResponse, Cert, Settings, ClientBase, Vmess/VLESS/
  Trojan/Shadowsocks/Hysteria/Tunnel/Mixed/Http/Wireguard/TunSettings)
- All gen*Link, applyXhttpExtra*, applyExternalProxyTLS*, applyFinalMask*
  and related helpers explicitly any-typed
- Constructor positional client-args (email, limitIp, totalGB, …) typed
  as optional any across Vmess/VLESS/Trojan/Shadowsocks/Hysteria.VMESS|
  VLESS|Trojan|Shadowsocks|Hysteria
- File-level eslint-disable for no-explicit-any/prefer-const/
  no-case-declarations/no-array-constructor to silence churn without
  changing behavior

* refactor(frontend): port models/dbinbound to TypeScript

Phase 6 — final phase of the JS→TS migration. Frontend src/ no
longer contains any *.js files.

- DBInbound declares all fields explicitly (id, userId, up, down,
  total, …, nodeId, fallbackParent) with proper types
- _expiryTime getter/setter typed against dayjs.Dayjs
- coerceInboundJsonField takes unknown, returns any
- Private cache fields (_cachedInbound, _clientStatsMap) declared
- Consumers (InboundFormModal, InboundsPage, useInbounds): drop ".js"
  extension from @/models/dbinbound imports

* refactor(frontend): drop .js extensions from TS-resolved imports

Cleanup after the JS→TS migration:

- All consumers that imported @/models/{inbound,outbound,dbinbound}.js
  now drop the .js extension (TS module resolution lands on the .ts
  file automatically)
- eslint.config.js: remove the **/*.js block since the only remaining
  JS file under src/ is endpoints.js (build-script consumed only) and
  js.configs.recommended already covers it correctly

* refactor(frontend): tighten inbound.ts cleanup wins

Checkpoint before the full any → typed pass:
- Wrap 15 case bodies in braces (no-case-declarations)
- Convert 14 let → const in genLink helpers (prefer-const)
- new Array() → [] for shadowsocks passwords (no-array-constructor)
- XrayCommonClass: HeaderEntry, FallbackEntry, JsonObject interfaces;
  fromJson/toV2Headers/toHeaders typed against them; static methods
  return JsonObject / HeaderEntry[] instead of any
- Reduce file-level eslint-disable scope from 4 rules to just
  no-explicit-any (the only one still needed)

* refactor(frontend): drop eslint-disable from models/dbinbound

Replace `any` with explicit domain types:
- `coerceInboundJsonField` returns `Record<string, unknown>` (settings/streamSettings/sniffing are always objects).
- Add `RawJsonField`, `ClientStats`, `FallbackParentRef`, `DBInboundInit` types.
- `_cachedInbound: Inbound | null`, `toInbound(): Inbound`.
- `getClientStats(email): ClientStats | undefined`.
- `genInboundLinks(): string` (matches actual return from Inbound.genInboundLinks).
- Constructor now accepts `DBInboundInit`.

* refactor(frontend): drop eslint-disable from InboundsPage

Type all callbacks against DBInbound from @/models/dbinbound:
- state setters use DBInbound | null
- helpers (projectChildThroughMaster, checkFallback, findClientIndex,
  exportInboundLinks, etc.) take DBInbound
- drop `(dbInbounds as any[])` casts; useInbounds already returns DBInbound[]
- introduce ClientMatchTarget for findClientIndex's `client` param
- tighten DBInbound.clientStats to ClientStats[] (default [])
- single boundary cast at <InboundList onRowAction=> to bridge
  InboundList's narrower DBInboundRecord (cleanup belongs with InboundList)

* refactor(frontend): drop file-level eslint-disable from utils/index

- ObjectUtil.clone/deepClone become generic <T>
- cloneProps/delProps accept `object` (cast internally to AnyRecord)
- equals accepts `unknown` with proper narrowing
- ColorUtils.usageColor narrows data/threshold to `number`; total widened
  to `number | { valueOf(): number } | null | undefined` so Dayjs works
- Utils.debounce replaces `const self = this` with lexical arrow
  closure (no-this-alias clean)
- InboundList._expiryTime narrowed from `unknown` to `{ valueOf(): number } | null`
- Single-line eslint-disable remains on `Msg<T = any>` and HttpUtil
  generic defaults (idiomatic API envelope; changing default to unknown
  cascades through 34 consumer files)

* refactor(frontend): drop eslint-disable from OutboundFormModal field section

Replace `type OB = any` with `type OB = Outbound`. Body code still
sees protocol fields as `any` via Outbound's inherited [key: string]: any
index signature (CommonClass) — that escape hatch will narrow as
Phase 6 tightens outbound.ts itself.

The intentional `// eslint-disable-next-line` on `useRef<any>(null)`
at line 72 stays — out of scope per plan.

* refactor(frontend): drop file-level eslint-disable from InboundFormModal

Add minimal local interfaces for protocol-specific shapes the form reads:
- StreamLike, TlsCert, VlessClient, ShadowsocksClient, HttpAccount,
  WireguardPeer (replace with real exports from inbound.ts as Phase 7
  exports them).
- Props typed as DBInbound | null + DBInbound[].
- Drop unnecessary `(Inbound as any).X`, `(RandomUtil as any).X`,
  `(Wireguard as any).X`, `(DBInbound as any)(...)` casts — they are
  already typed classes; only `Inbound.Settings`/`Inbound.HttpSettings`
  remain `any` via static field on Inbound (will tighten in Phase 7).
- inboundRef/dbFormRef retain single-line `// eslint-disable-next-line`
  for `useRef<any>(null)` — nullable narrowing across ~30 callsites
  exceeds Phase 5 scope.
- payload locals typed Record<string, unknown>; setAdvancedAllValue
  parses JSON into a narrowed object instead of `let parsed: any`.

* refactor(frontend): narrow outbound.ts eslint-disable to no-explicit-any only

- Fix all 36 prefer-const violations: convert never-reassigned `let` to
  `const`; for mixed-mutability destructuring (fromParamLink,
  fromHysteriaLink) split into separate `const`/`let` declarations
  by index instead of destructuring.
- Fix both no-var violations: `var stream` / `var settings` → `let`.
- File still carries `/* eslint-disable @typescript-eslint/no-explicit-any */`
  because tightening 223 `any` uses requires removing CommonClass's
  `[key: string]: any` escape hatch and reshaping ~30 dynamically-attached
  subclass patterns into named classes — multi-hour architectural work
  tracked as Phase 7's twin for outbound.

* refactor(frontend): align sub page chrome with login + AntD defaults

- Theme + language buttons now both use AntD `<Button shape="circle"
  size="large" className="toolbar-btn">` with TranslationOutlined and
  the SVG theme icon — identical hover/border behaviour.
- Language popover content switched from hand-rolled `<ul.lang-list>`
  to AntD `<Menu mode="vertical" selectable />`; gains native
  hover/keyboard nav + active highlight.
- Drop `.info-table` `!important` border overrides (8 selectors) so
  Descriptions inherits the AntD theme border colour.
- Drop `.qr-code` padding/background/border-radius overrides; only
  `cursor: pointer` remains (QRCode handles padding/bg itself).
- Remove now-unused `.theme-cycle`, `.lang-list`, `.lang-item*`,
  `.lang-select`, `.settings-popover` rules.

* refactor(frontend): drop CustomStatistic wrapper, move overrides to theme tokens

- Delete `<CustomStatistic>` (a pass-through wrapper over <Statistic>)
  and its unscoped global `.ant-statistic-*` CSS overrides; consumers
  (IndexPage, ClientsPage, InboundsPage, NodesPage) now import AntD
  `<Statistic>` directly.
- Add Statistic component tokens to ConfigProvider so the title (11px)
  and content (17px) font sizes still apply, without `!important`
  global selectors.
- Move dark / ultra-dark card border colours from `body.dark .ant-card`
  + `html[data-theme='ultra-dark'] .ant-card` selectors into Card
  `colorBorderSecondary` tokens; page-cards.css now only carries the
  custom radius/shadow/transition that has no token equivalent.
- Simplify XrayStatusCard badge: remove the custom `xray-pulse` dot
  keyframe and per-state ring-colour overrides; AntD `<Badge
  status="processing" color={…}>` already pulses the ring in the same
  colour, no extra CSS needed.

* refactor(frontend): modernize login page with AntD primitives

- Theme cycle button switched from `<button.theme-cycle>` + custom CSS
  to AntD `<Button shape="circle" className="toolbar-btn">` (matches
  sub page chrome already established).
- Theme icons switched from hand-rolled inline SVG (sun, moon,
  moon+star) to AntD `<SunOutlined />`, `<MoonOutlined />`,
  `<MoonFilled />` for the three light / dark / ultra-dark states.
- Language popover content switched from `<ul.lang-list>` +
  `<button.lang-item>` to AntD `<Menu mode="vertical" selectable />`
  with `selectedKeys=[lang]`; native hover / keyboard nav / active
  highlight come for free.
- Drop CSS for `.theme-cycle`, `.lang-list`, `.lang-item*` (now unused).
  `.toolbar-btn` retained since it sizes both circular buttons.

* refactor(frontend): switch sub page theme icons to AntD primitives

Replace the three hand-rolled SVG theme icons (sun, moon, moon+star)
with AntD `<SunOutlined />`, `<MoonOutlined />`, `<MoonFilled />`
for the light / dark / ultra-dark states. Switch the theme `<Button>`
to use the `icon` prop instead of children so it renders the same
way as the language button. Drop `.toolbar-btn svg` CSS — no longer
needed once the icon comes from AntD.

* refactor(frontend): drop !important overrides from pages CSS (Clients + Log modals + Settings tabs)

- ClientsPage: pagination size-changer `min-width !important` removed;
  the 3-level selector specificity already beats AntD's defaults.
  Scope `body.dark .client-card` to `.clients-page.is-dark .client-card`
  (avoid leaking into other pages).
- LogModal + XrayLogModal: move the mobile full-screen tweaks
  (`top: 0`, `padding-bottom: 0`, `max-width: 100vw`) from `!important`
  class rules to the Modal's `style` prop; keep `.ant-modal-content`
  / `.ant-modal-body` overrides as plain CSS via the className.
- SubscriptionFormatsTab: drop `display: block !important` on
  `.nested-block` — div is already block by default.
- TwoFactorModal: drop `padding/background/border-radius !important`
  on `.qr-code`; AntD QRCode handles those itself.

* refactor(frontend): scope dark overrides and switch list borders to AntD CSS variables

Scope page-level dark overrides:
- inbounds/InboundList: scope `.ant-table` border-radius rules and the
  mobile @media `.ant-card-*` tweaks to `.inbounds-page` (were global
  and leaked into other pages); scope `.inbound-card` dark variant to
  `.inbounds-page.is-dark`.
- nodes/NodeList: scope `.node-card` dark to `.nodes-page.is-dark`.
- xray/RoutingTab, OutboundsTab: scope `.rule-card`, `.criterion-chip`,
  `.criterion-more`, `.address-pill` dark to `.xray-page.is-dark`.

Modernize list borders to use AntD CSS vars instead of body.dark forks:
- index/BackupModal, PanelUpdateModal, VersionModal: replace
  hard-coded `rgba(5,5,5,0.06)` + `body.dark`/`html[data-theme]`
  override pairs with `var(--ant-color-border-secondary)`; replace
  custom text colours with `var(--ant-color-text)` /
  `var(--ant-color-text-tertiary)`.
- xray/DnsPresetsModal: same border-color treatment.
- xray/NordModal, WarpModal: collapse `.row-odd` light + `body.dark`
  pair into a single neutral `rgba(128,128,128,0.06)` that works on
  both themes; scope under `.nord-data-table` / `.warp-data-table`.

* refactor(frontend): switch shared components CSS to AntD CSS variables

Replace body.dark / html[data-theme] forks with AntD CSS variables
in shared components (work in both light and dark, scale to ultra):
- SettingListItem: borders + text colours via
  `--ant-color-border-secondary`, `--ant-color-text`,
  `--ant-color-text-tertiary`.
- InputAddon: bg/border/text via `--ant-color-fill-tertiary`,
  `--ant-color-border`, `--ant-color-text`.
- JsonEditor: host border/bg via `--ant-color-border`,
  `--ant-color-bg-container`; focus border via `--ant-color-primary`.
- Sparkline (SVG): grid/text colours via `--ant-color-text*`
  and `--ant-color-border-secondary`; only the tooltip drop-shadow
  retains a body.dark fork (filter opacity needs explicit value).

* refactor(frontend): swap custom Sparkline SVG for Recharts AreaChart

Replace the 368-line hand-rolled SVG sparkline (with manual
ResizeObserver, gradient/shadow/glow filters, grid + ticks + tooltip,
custom Y-axis label thinning) with a thin Recharts `<AreaChart>`
wrapper that keeps the same prop API.

- Preserved props: data, labels, height, stroke, strokeWidth,
  maxPoints, showGrid, fillOpacity, showMarker, markerRadius,
  showAxes, yTickStep, tickCountX, showTooltip, valueMin, valueMax,
  yFormatter, tooltipFormatter.
- Dropped: `vbWidth`, `gridColor`, `paddingLeft/Right/Top/Bottom` —
  Recharts' ResponsiveContainer handles width, and margins are wired
  to whether axes are visible. Removed the unused `vbWidth` prop from
  SystemHistoryModal, XrayMetricsModal, NodeHistoryPanel callsites.
- Tooltip, grid, and axis text now use AntD CSS variables for
  automatic light/dark adaptation; replaced the SVG body.dark forks
  in Sparkline.css with a single 5-line stylesheet.
- Bundle: vendor +~100KB gzip (Recharts + its d3 deps), trade-off
  for less custom chart code to maintain and a more standard API
  for future charts (multi-series, brush, etc.).

* build(frontend): split Recharts + d3 deps into vendor-recharts chunk

Pulls Recharts (~75KB gzip) and its d3-shape/array/color/path/scale
+ victory-vendor deps out of the catch-all vendor chunk so they
load on demand on the three pages that use Sparkline
(SystemHistoryModal, XrayMetricsModal, NodeHistoryPanel) and cache
independently from the rest of the panel JS.

* refactor(frontend): drop body.dark forks in favor of AntD CSS variables

- ClientInfoModal/InboundInfoModal: link-panel-text and link-panel-anchor now use
  var(--ant-color-fill-tertiary) and color-mix on --ant-color-primary, removing
  the body.dark light/dark background pair.
- InboundFormModal: advanced-panel uses --ant-color-border-secondary and
  --ant-color-fill-quaternary; body.dark/html[data-theme='ultra-dark'] pair gone.
- CustomGeoSection: custom-geo-count, custom-geo-ext-code, custom-geo-copyable:hover
  use --ant-color-fill-tertiary/-secondary; body.dark forks gone.
- SystemHistoryModal: cpu-chart-wrap collapsed from three theme-specific gradients
  into one using color-mix on --ant-color-primary and --ant-color-fill-quaternary.
- page-cards.css: body.dark / html[data-theme='ultra-dark'] selectors renamed to
  page-scoped .is-dark / .is-dark.is-ultra, keeping the same shadow tuning but
  consistent with the page-scoping convention used elsewhere.

* refactor(sidebar): modernize AppSidebar with AntD CSS variables and icons

- Replace hardcoded rgba(0,0,0,X) colors with var(--ant-color-text)
  and var(--ant-color-text-secondary) so light/dark adapt automatically.
- Replace rgba(128,128,128,0.15) borders with var(--ant-color-border-secondary)
  and rgba(128,128,128,0.18) backgrounds with var(--ant-color-fill-tertiary).
- Drop all body.dark/html[data-theme='ultra-dark'] color forks for
  .drawer-brand, .sider-brand, .drawer-close, .sidebar-theme-cycle,
  .sidebar-donate (CSS variables already adapt).
- Drop the body.dark Drawer background !important pair; AntD's
  colorBgElevated token from the dark algorithm handles it now.
- Replace inline sun/moon SVGs in ThemeCycleButton with AntD's
  SunOutlined/MoonOutlined/MoonFilled to match LoginPage/SubPage.
- Convert .sidebar-theme-cycle hover and the menu item selected/hover
  highlights from hardcoded #4096ff to color-mix on --ant-color-primary,
  keeping !important on menu rules to beat AntD's CSS-in-JS specificity.

* refactor(frontend): swap hardcoded AntD palette colors for CSS variables

The dot/badge/pill styles still hardcoded AntD's default palette values
(#52c41a, #1677ff, #ff4d4f, #fa8c16, #ff4d4f). Replace each with its
semantic --ant-color-* equivalent so they auto-adapt to any theme
customization through ConfigProvider.

- ClientsPage: .dot-green/.dot-blue/.dot-red/.dot-orange/.dot-gray now
  use --ant-color-success / -primary / -error / -warning / -text-quaternary.
  .bulk-count / .client-card / .client-card.is-selected backgrounds use
  color-mix on --ant-color-primary and --ant-color-fill-quaternary, which
  also let the body-dark .client-card fork go away.
- XrayMetricsModal: .obs-dot is-alive/is-dead and its pulse keyframe now
  build their box-shadow tint via color-mix on --ant-color-success and
  --ant-color-error instead of rgba literals.
- IndexPage: .action-update warning color uses --ant-color-warning.
- OutboundsTab: .outbound-card border, .address-pill background, and
  .mode-badge tint now use AntD CSS variables; the .xray-page.is-dark
  .address-pill fork is gone.
- InboundFormModal/InboundsPage/ClientBulkAddModal: drop the stale
  `, #1677ff`/`, #1890ff` fallbacks on var(--ant-color-primary), and
  switch .danger-icon to --ant-color-error.

The teal/cyan brand colors (#008771, #3c89e8, #e04141) used by traffic
and pill rows are intentionally kept hardcoded — they are brand-specific
shades, not AntD palette colors.

* refactor(frontend): swap neutral gray rgba literals for AntD CSS variables

Across 12 files the same neutral grays kept reappearing — rgba(128,128,128,
0.06|0.08|0.12|0.15|0.18|0.2|0.25) for borders, dividers, and subtle
backgrounds. Each maps cleanly to an AntD CSS variable that already
adapts to light/dark and to any theme customization through ConfigProvider:

- 0.12–0.18 borders → var(--ant-color-border-secondary)
- 0.2–0.25 borders → var(--ant-color-border)
- 0.06–0.08 backgrounds → var(--ant-color-fill-tertiary)
- 0.02–0.03 card surfaces → var(--ant-color-fill-quaternary)

Card surfaces (InboundList .inbound-card, NodeList .node-card) had a
light/dark fork pair — the variable covers both, so the .is-dark .card
override is gone.

RoutingTab .rule-card.drop-before/after used hardcoded #1677ff for the
inset focus shadow; replaced with var(--ant-color-primary) so reordering
indicators follow the theme primary.

ClientsPage bucketBadgeColor returned hex literals (#ff4d4f, #fa8c16,
#52c41a, rgba gray) for a Badge color prop. Switched to status="error"|
"warning"|"success"|"default" so the dot color now comes from AntD's
semantic palette directly.

* refactor(xray): collapse RoutingTab dark forks into AntD CSS variables

- .criterion-more bg light/dark fork → var(--ant-color-fill-tertiary)
- .xray-page.is-dark .rule-card and .criterion-chip overrides removed;
  the rules already use --bg-card and --ant-color-fill-tertiary that
  adapt to the theme on their own.

* refactor(frontend): inline style hex literals and Alert icon redundancy

- FinalMaskForm: five DeleteOutlined icons used rgb(255,77,79) inline;
  swap for var(--ant-color-error) so they follow theme customization.
- NodesPage: CheckCircleOutlined / CloseCircleOutlined statistic prefixes
  switch to var(--ant-color-success) / -error.
- NodeList: ExclamationCircleOutlined warning icons (two callsites) now
  use var(--ant-color-warning).
- BasicsTab: four <Alert type="warning"> blocks shipped a custom
  ExclamationCircleFilled icon styled to match the warning palette —
  exactly the icon and color AntD Alert renders for type="warning" by
  default. Replace the icon prop with showIcon and drop the now-unused
  ExclamationCircleFilled import.
- JsonEditor: focus-within box-shadow tint now uses color-mix on
  --ant-color-primary instead of an rgba(22,119,255,0.1) literal.

* refactor(logs): collapse log-container dark forks to AntD CSS variables

LogModal and XrayLogModal each had a body.dark fork that overrode the
log container's background, border-color, and text color in addition
to the --log-* severity tokens. Background/border/color all map cleanly
to var(--ant-color-fill-tertiary) / var(--ant-color-border) /
var(--ant-color-text) which already adapt to the theme, so only the
severity color tokens remain inside the dark/ultra-dark blocks.

* refactor(xray): drop stale --ant-primary-color fallbacks and hex literals

- RoutingTab .drop-before/.drop-after box-shadow: #1677ff → var(--ant-color-primary)
- OutboundFormModal .random-icon: drop the --ant-primary-color/#1890ff
  pair (the old AntD v4 token name with stale fallback) for the v6
  --ant-color-primary; .danger-icon hex #ff4d4f → var(--ant-color-error).
- XrayPage .restart-icon: same drop of the --ant-primary-color fallback.

These were all leftovers from the AntD v4 → v6 rename — the v6
--ant-color-primary is already populated by ConfigProvider, so the
fallback hex was dead code that would only trigger if AntD wasn't
mounted.

* refactor(frontend): consolidate margin utility classes into one stylesheet

Page CSS files each carried their own copies of the same atomic margin
utilities (.mt-4, .mt-8, .mb-12, .ml-8, .my-10, ...). The definitions
were identical everywhere they appeared, with each file holding only
the subset it happened to need.

Move all of them into a single styles/utils.css imported once from
main.tsx, and delete the per-page copies from InboundFormModal,
CustomGeoSection, PanelUpdateModal, VersionModal, BasicsTab, NordModal,
OutboundFormModal, and WarpModal. The classes are available globally
on the panel app; login.tsx and subpage.tsx entries do not consume any
of them so they stay untouched.

* refactor(frontend): consolidate shared page-shell rules into one stylesheet

Every panel page CSS file repeated the same wrapper boilerplate — the
--bg-page/--bg-card token triples for light/dark/ultra-dark, the
min-height + background root rule, the .ant-layout transparent reset,
the .content-shell transparent reset, and the .loading-spacer min-height.
That's ~30 identical lines duplicated across IndexPage, ClientsPage,
InboundsPage, XrayPage, SettingsPage, NodesPage, and ApiDocsPage.

Move all of it into styles/page-shell.css and import it once from
main.tsx alongside utils.css and page-cards.css. Each page CSS file
now only contains genuinely page-specific rules (content-area padding
overrides, page-specific tokens like ApiDocs's Swagger --sw-* set).

Also drop the per-page `import '@/styles/page-cards.css'` statements
from the 7 page tsx files now that main.tsx loads it globally.

Net: -211 deleted, +6 inserted in the touched files, plus the new
page-shell.css. .zero-margin (Divider override used by Nord/Warp
modals) folded into utils.css alongside the margin classes.

* refactor(frontend): move default content-area padding to page-shell.css

After page-shell.css landed, six of the seven panel pages still kept an
identical `.X-page .content-area { padding: 24px }` desktop rule, plus
three of them kept an identical `padding: 8px` mobile rule. Hoist both
defaults into page-shell.css under a single 6-page selector group and
delete the per-page copies.

What stays page-specific:
- IndexPage keeps its mobile override (padding 12px + padding-top: 64px
  for the fixed drawer handle clearance).
- ApiDocsPage keeps its tighter desktop padding (16px) and its own
  mobile padding-top: 56px.

Settings .ldap-no-inbounds also switches from #999 to
var(--ant-color-text-tertiary) for theme adaptation.

* refactor(frontend): hoist .header-row, .icons-only, .summary-card to page-shell.css

Settings and Xray pages both carried identical .header-row /
.header-actions / .header-info rules and an identical six-rule
.icons-only block that styles tabbed page navigation. Clients, Inbounds,
and Nodes all carried identical .summary-card padding rules with the
same mobile reduction. None of these are page-specific.

Consolidate:
- .header-row family → page-shell scoped to .settings-page, .xray-page
- .icons-only family → page-shell global (the class is a deliberate
  opt-in marker, no scope needed)
- .summary-card → page-shell scoped to .clients-page, .inbounds-page,
  .nodes-page (also fixes InboundsPage's missing scope — its rule was
  global and would have matched stray .summary-card uses elsewhere)

InboundsPage.css and NodesPage.css became empty after the move so the
files and their per-page imports are deleted.

* refactor(frontend): hoist .random-icon to utils.css

Three form modals each carried identical .random-icon styles (small
primary-tinted icon next to randomizable inputs):
  ClientBulkAddModal, InboundFormModal, OutboundFormModal

Single definition lives in utils.css now. ClientBulkAddModal.css was
just this one rule, so the file and its import are deleted along the way.

.danger-icon is left per file — the margin-left differs slightly
between InboundFormModal (6px) and OutboundFormModal (8px), so it
stays as a page-local rule rather than getting averaged into utils.css.

* refactor(frontend): hoist .danger-icon to utils.css and use it everywhere

InboundFormModal (margin-left 6px) and OutboundFormModal (margin-left
8px) each carried their own .danger-icon, and FinalMaskForm wrote the
same color/cursor/marginLeft trio inline five times. Unify on a single
.danger-icon in utils.css with margin-left: 8px — matching the more
generous OutboundFormModal value — and:
- Drop the per-file .danger-icon copies from InboundFormModal.css and
  OutboundFormModal.css.
- Replace the five inline style props in FinalMaskForm.tsx with
  className="danger-icon".

The visible change is a 2px wider gap to the right of the delete icons
on InboundFormModal's protocol/peer dividers.
2026-05-25 14:34:53 +02:00

1472 lines
54 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Form,
Input,
InputNumber,
message,
Modal,
Radio,
Select,
Space,
Switch,
Tabs,
Checkbox,
} from 'antd';
import { SyncOutlined, PlusOutlined, MinusOutlined, DeleteOutlined } from '@ant-design/icons';
import { Wireguard } from '@/utils';
import InputAddon from '@/components/InputAddon';
import {
Outbound,
Protocols,
SSMethods,
TLS_FLOW_CONTROL,
UTLS_FINGERPRINT,
ALPN_OPTION,
SNIFFING_OPTION,
USERS_SECURITY,
OutboundDomainStrategies,
WireguardDomainStrategy,
Address_Port_Strategy,
MODE_OPTION,
DNSRuleActions,
} from '@/models/outbound';
import FinalMaskForm from '@/components/FinalMaskForm';
import JsonEditor from '@/components/JsonEditor';
import './OutboundFormModal.css';
interface OutboundFormModalProps {
open: boolean;
outbound: Record<string, unknown> | null;
existingTags: string[];
onClose: () => void;
onConfirm: (outbound: Record<string, unknown>) => void;
}
const PROTOCOL_OPTIONS = Object.values(Protocols) as string[];
const SECURITY_OPTIONS = Object.values(USERS_SECURITY) as string[];
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL) as string[];
const UTLS_OPTIONS = Object.values(UTLS_FINGERPRINT) as string[];
const ALPN_OPTIONS = Object.values(ALPN_OPTION) as string[];
const NETWORKS = ['tcp', 'kcp', 'ws', 'grpc', 'httpupgrade', 'xhttp'];
const NETWORK_LABELS: Record<string, string> = {
tcp: 'TCP (RAW)',
kcp: 'mKCP',
ws: 'WebSocket',
grpc: 'gRPC',
httpupgrade: 'HTTPUpgrade',
xhttp: 'XHTTP',
};
export default function OutboundFormModal({
open,
outbound: outboundProp,
existingTags,
onClose,
onConfirm,
}: OutboundFormModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const outboundRef = useRef<any>(null);
const [, setTick] = useState(0);
const [activeKey, setActiveKey] = useState('1');
const [linkInput, setLinkInput] = useState('');
const [advancedJson, setAdvancedJson] = useState('');
const revertingTabRef = useRef(false);
const isEdit = outboundProp != null;
const refresh = useCallback(() => setTick((n) => n + 1), []);
const primeAdvancedJson = useCallback(() => {
const ob = outboundRef.current;
if (!ob) {
setAdvancedJson('');
return;
}
try {
setAdvancedJson(JSON.stringify(ob.toJson(), null, 2));
} catch {
setAdvancedJson('');
}
}, []);
useEffect(() => {
if (!open) return;
outboundRef.current = outboundProp
? Outbound.fromJson(outboundProp)
: new Outbound();
setActiveKey('1');
setLinkInput('');
primeAdvancedJson();
refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, outboundProp]);
function applyAdvancedJsonToForm(): boolean {
const raw = advancedJson.trim();
if (!raw) return true;
const ob = outboundRef.current;
let currentJson = '';
try {
currentJson = JSON.stringify(ob?.toJson() ?? {}, null, 2);
} catch {
/* ignore */
}
if (raw === currentJson.trim()) return true;
let parsed;
try {
parsed = JSON.parse(raw);
} catch (e) {
messageApi.error(`JSON: ${(e as Error).message}`);
return false;
}
try {
const fallbackTag = ob?.tag;
const next = Outbound.fromJson(parsed);
if (!next.tag && fallbackTag) next.tag = fallbackTag;
outboundRef.current = next;
refresh();
return true;
} catch (e) {
messageApi.error(`JSON: ${(e as Error).message}`);
return false;
}
}
function onTabChange(key: string) {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
if (revertingTabRef.current) {
revertingTabRef.current = false;
setActiveKey(key);
return;
}
const prev = activeKey;
if (key === '2') {
primeAdvancedJson();
setActiveKey(key);
} else if (key === '1' && prev === '2') {
if (!applyAdvancedJsonToForm()) {
revertingTabRef.current = true;
setActiveKey('2');
} else {
setActiveKey(key);
}
} else {
setActiveKey(key);
}
}
const ob = outboundRef.current;
const proto = ob?.protocol;
const isVMess = proto === Protocols.VMess;
const isVLESS = proto === Protocols.VLESS;
const isVMessOrVLess = isVMess || isVLESS;
const isTrojan = proto === Protocols.Trojan;
const isShadowsocks = proto === Protocols.Shadowsocks;
const isFreedom = proto === Protocols.Freedom;
const isBlackhole = proto === Protocols.Blackhole;
const isDNS = proto === Protocols.DNS;
const isWireguard = proto === Protocols.Wireguard;
const isHysteria = proto === Protocols.Hysteria;
const isLoopback = proto === Protocols.Loopback;
function onProtocolChange(next: string) {
if (!ob) return;
ob.protocol = next;
refresh();
}
function streamNetworkChange(next: string) {
if (!ob?.stream) return;
ob.stream.network = next;
if (!ob.canEnableTls()) ob.stream.security = 'none';
refresh();
}
function regenerateWgKeys() {
if (!ob?.settings) return;
const pair = Wireguard.generateKeypair();
ob.settings.secretKey = pair.privateKey;
ob.settings.pubKey = pair.publicKey;
refresh();
}
const duplicateTag = useMemo(() => {
if (!ob?.tag) return false;
const myTag = ob.tag.trim();
if (!myTag) return false;
if (isEdit && (outboundProp?.tag as string | undefined) === myTag) return false;
return (existingTags || []).includes(myTag);
}, [ob?.tag, existingTags, isEdit, outboundProp]);
const tagEmpty = !ob?.tag?.trim();
const tagValidateStatus: 'error' | 'warning' | 'success' = tagEmpty
? 'error'
: duplicateTag
? 'warning'
: 'success';
const tagHelp = tagEmpty
? 'Tag is required'
: duplicateTag
? 'Tag already used by another outbound'
: '';
function onOk() {
if (!ob) return;
if (activeKey === '2' && !applyAdvancedJsonToForm()) return;
if (!ob.tag?.trim()) {
messageApi.error('Tag is required');
return;
}
if (duplicateTag) {
messageApi.error('Tag already used by another outbound');
return;
}
onConfirm(ob.toJson());
}
function convertLink() {
const link = linkInput.trim();
if (!link) return;
try {
const next = Outbound.fromLink(link);
if (!next) {
messageApi.error('Wrong Link!');
return;
}
outboundRef.current = next;
primeAdvancedJson();
setLinkInput('');
messageApi.success('Link imported successfully...');
setActiveKey('1');
refresh();
} catch (e) {
messageApi.error(`Link parse: ${(e as Error).message}`);
}
}
const title = isEdit
? `${t('edit')} ${t('pages.xray.Outbounds')}`
: `+ ${t('pages.xray.Outbounds')}`;
const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
if (!ob) {
return (
<>
{messageContextHolder}
<Modal open={open} title={title} footer={null} onCancel={onClose} />
</>
);
}
return (
<>
{messageContextHolder}
<Modal
open={open}
title={title}
okText={okText}
cancelText={t('close')}
mask={{ closable: false }}
width={780}
onOk={onOk}
onCancel={onClose}
>
<Tabs
activeKey={activeKey}
onChange={onTabChange}
items={[
{
key: '1',
label: t('pages.xray.basicTemplate'),
children: (
<>
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
<Form.Item label={t('protocol')}>
<Select
value={proto}
onChange={onProtocolChange}
options={PROTOCOL_OPTIONS.map((p) => ({ value: p, label: p }))}
/>
</Form.Item>
<Form.Item label="Tag" validateStatus={tagValidateStatus} help={tagHelp} hasFeedback>
<Input
value={ob.tag}
placeholder="unique-tag"
onChange={(e) => {
ob.tag = e.target.value;
refresh();
}}
/>
</Form.Item>
<Form.Item label="Send through">
<Input
value={ob.sendThrough || ''}
placeholder="local IP"
onChange={(e) => {
ob.sendThrough = e.target.value;
refresh();
}}
/>
</Form.Item>
{isFreedom && <FreedomFields ob={ob} refresh={refresh} />}
{isBlackhole && (
<Form.Item label="Response Type">
<Select
value={ob.settings.type || ''}
onChange={(v) => { ob.settings.type = v; refresh(); }}
options={[
{ value: '', label: '(empty)' },
{ value: 'none', label: 'none' },
{ value: 'http', label: 'http' },
]}
/>
</Form.Item>
)}
{isLoopback && (
<Form.Item label="Inbound tag">
<Input
value={ob.settings.inboundTag || ''}
placeholder="inbound tag using in routing rules"
onChange={(e) => { ob.settings.inboundTag = e.target.value; refresh(); }}
/>
</Form.Item>
)}
{isDNS && <DnsFields ob={ob} refresh={refresh} t={t} />}
{isWireguard && <WireguardFields ob={ob} refresh={refresh} regenerate={regenerateWgKeys} t={t} />}
{ob.hasAddressPort() && (
<>
<Form.Item label={t('pages.inbounds.address')}>
<Input
value={ob.settings.address || ''}
onChange={(e) => { ob.settings.address = e.target.value; refresh(); }}
/>
</Form.Item>
<Form.Item label={t('pages.inbounds.port')}>
<InputNumber
value={ob.settings.port || 0}
min={1}
max={65535}
onChange={(v) => { ob.settings.port = Number(v) || 0; refresh(); }}
/>
</Form.Item>
</>
)}
{isVMessOrVLess && (
<VMessVLessFields ob={ob} refresh={refresh} isVMess={isVMess} isVLESS={isVLESS} t={t} />
)}
{(isTrojan || isShadowsocks) && (
<Form.Item label={t('password')}>
<Input
value={ob.settings.password || ''}
onChange={(e) => { ob.settings.password = e.target.value; refresh(); }}
/>
</Form.Item>
)}
{isShadowsocks && (
<>
<Form.Item label={t('encryption')}>
<Select
value={ob.settings.method}
onChange={(v) => { ob.settings.method = v; refresh(); }}
options={Object.entries(SSMethods).map(([k, v]) => ({ value: v as string, label: k }))}
/>
</Form.Item>
<Form.Item label="UDP over TCP">
<Switch checked={!!ob.settings.uot} onChange={(v) => { ob.settings.uot = v; refresh(); }} />
</Form.Item>
<Form.Item label="UoT version">
<InputNumber
value={ob.settings.UoTVersion ?? 1}
min={1}
max={2}
onChange={(v) => { ob.settings.UoTVersion = Number(v) || 1; refresh(); }}
/>
</Form.Item>
</>
)}
{ob.hasUsername() && (
<>
<Form.Item label={t('username')}>
<Input
value={ob.settings.user || ''}
onChange={(e) => { ob.settings.user = e.target.value; refresh(); }}
/>
</Form.Item>
<Form.Item label={t('password')}>
<Input
value={ob.settings.pass || ''}
onChange={(e) => { ob.settings.pass = e.target.value; refresh(); }}
/>
</Form.Item>
</>
)}
{isHysteria && (
<Form.Item label="Version">
<InputNumber value={ob.settings.version || 2} min={2} max={2} disabled />
</Form.Item>
)}
{ob.canEnableStream() && (
<StreamFields ob={ob} refresh={refresh} streamNetworkChange={streamNetworkChange} isHysteria={isHysteria} t={t} />
)}
{ob.canEnableTls() && <TlsFields ob={ob} refresh={refresh} t={t} />}
{ob.stream && <SockoptFields ob={ob} refresh={refresh} />}
{ob.canEnableMux() && <MuxFields ob={ob} refresh={refresh} t={t} />}
</Form>
{ob.stream && ob.canEnableStream() && (
<FinalMaskForm stream={ob.stream} protocol={proto} onChange={refresh} />
)}
</>
),
},
{
key: '2',
label: 'JSON',
children: (
<Space orientation="vertical" size={10} style={{ width: '100%', marginTop: 10 }}>
<Input.Search
value={linkInput}
placeholder="vmess:// vless:// trojan:// ss:// hysteria2://"
enterButton="Convert"
onChange={(e) => setLinkInput(e.target.value)}
onSearch={convertLink}
/>
<JsonEditor
value={advancedJson}
onChange={setAdvancedJson}
minHeight="360px"
maxHeight="600px"
/>
</Space>
),
},
]}
/>
</Modal>
</>
);
}
type OB = Outbound;
interface FieldProps {
ob: OB;
refresh: () => void;
}
interface TFieldProps extends FieldProps {
t: (k: string) => string;
}
function FreedomFields({ ob, refresh }: FieldProps) {
const fragment = (ob.settings.fragment || {}) as Record<string, string>;
const noises = (ob.settings.noises || []) as Array<{ type: string; packet: string; delay: string; applyTo: string }>;
const finalRules = (ob.settings.finalRules || []) as Array<{ action: string; network?: string; port?: string; ip?: string[]; blockDelay?: string }>;
return (
<>
<Form.Item label="Strategy">
<Select
value={ob.settings.domainStrategy}
onChange={(v) => { ob.settings.domainStrategy = v; refresh(); }}
options={(OutboundDomainStrategies as string[]).map((s) => ({ value: s, label: s }))}
/>
</Form.Item>
<Form.Item label="Redirect">
<Input
value={ob.settings.redirect || ''}
onChange={(e) => { ob.settings.redirect = e.target.value; refresh(); }}
/>
</Form.Item>
<Form.Item label="Fragment">
<Switch
checked={!!ob.settings.fragment && Object.keys(ob.settings.fragment).length > 0}
onChange={(checked) => {
ob.settings.fragment = checked
? { packets: 'tlshello', length: '100-200', interval: '10-20', maxSplit: '300-400' }
: {};
refresh();
}}
/>
</Form.Item>
{ob.settings.fragment && Object.keys(ob.settings.fragment).length > 0 && (
<>
<Form.Item label="Packets">
<Select
value={fragment.packets}
onChange={(v) => { (ob.settings.fragment as Record<string, string>).packets = v; refresh(); }}
options={[
{ value: '1-3', label: '1-3' },
{ value: 'tlshello', label: 'tlshello' },
]}
/>
</Form.Item>
{(['length', 'interval', 'maxSplit'] as const).map((field) => (
<Form.Item key={field} label={field === 'maxSplit' ? 'Max Split' : field[0].toUpperCase() + field.slice(1)}>
<Input
value={fragment[field] || ''}
onChange={(e) => { (ob.settings.fragment as Record<string, string>)[field] = e.target.value; refresh(); }}
/>
</Form.Item>
))}
</>
)}
<Form.Item label="Noises">
<Switch
checked={noises.length > 0}
onChange={(checked) => {
ob.settings.noises = checked ? [new Outbound.FreedomSettings.Noise()] : [];
refresh();
}}
/>
{noises.length > 0 && (
<Button
size="small"
type="primary"
className="ml-8"
icon={<PlusOutlined />}
onClick={() => { (ob.settings.noises as unknown[]).push(new Outbound.FreedomSettings.Noise()); refresh(); }}
/>
)}
</Form.Item>
{noises.map((noise, index) => (
<div key={index}>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<div className="item-heading">
<span>Noise {index + 1}</span>
{noises.length > 1 && (
<DeleteOutlined
className="danger-icon"
onClick={() => { (ob.settings.noises as unknown[]).splice(index, 1); refresh(); }}
/>
)}
</div>
</Form.Item>
<Form.Item label="Type">
<Select
value={noise.type}
onChange={(v) => { noise.type = v; refresh(); }}
options={['rand', 'base64', 'str', 'hex'].map((x) => ({ value: x, label: x }))}
/>
</Form.Item>
<Form.Item label="Packet">
<Input value={noise.packet} onChange={(e) => { noise.packet = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label="Delay (ms)">
<Input value={noise.delay} onChange={(e) => { noise.delay = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label="Apply to">
<Select
value={noise.applyTo}
onChange={(v) => { noise.applyTo = v; refresh(); }}
options={['ip', 'ipv4', 'ipv6'].map((x) => ({ value: x, label: x }))}
/>
</Form.Item>
</div>
))}
<Form.Item label="Final Rules">
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
onClick={() => { ob.settings.addFinalRule('allow'); refresh(); }}
/>
<span className="ml-8" style={{ opacity: 0.6 }}>
Override Xray&apos;s default private-IP block (needed for LAN access through proxy)
</span>
</Form.Item>
{finalRules.map((rule, index) => (
<div key={`fr-${index}`}>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<div className="item-heading">
<span>Rule {index + 1}</span>
<DeleteOutlined
className="danger-icon"
onClick={() => { ob.settings.delFinalRule(index); refresh(); }}
/>
</div>
</Form.Item>
<Form.Item label="Action">
<Select
value={rule.action}
onChange={(v) => { rule.action = v; refresh(); }}
options={['allow', 'block'].map((x) => ({ value: x, label: x }))}
/>
</Form.Item>
<Form.Item label="Network">
<Select
value={rule.network}
allowClear
placeholder="(any)"
onChange={(v) => { rule.network = v; refresh(); }}
options={['tcp', 'udp', 'tcp,udp'].map((x) => ({ value: x, label: x }))}
/>
</Form.Item>
<Form.Item label="Port">
<Input
value={rule.port}
placeholder="e.g. 80,443 or 1000-2000"
onChange={(e) => { rule.port = e.target.value; refresh(); }}
/>
</Form.Item>
<Form.Item label="IP / CIDR / geoip">
<Select
mode="tags"
value={rule.ip || []}
tokenSeparators={[',', ' ']}
placeholder="e.g. 10.0.0.0/8, geoip:private, ext:cn.dat:cn"
onChange={(v) => { rule.ip = v as string[]; refresh(); }}
/>
</Form.Item>
{rule.action === 'block' && (
<Form.Item label="Block delay (ms)">
<Input
value={rule.blockDelay}
placeholder="optional: 5000-10000"
onChange={(e) => { rule.blockDelay = e.target.value; refresh(); }}
/>
</Form.Item>
)}
</div>
))}
</>
);
}
function DnsFields({ ob, refresh, t }: TFieldProps) {
const rules = (ob.settings.rules || []) as Array<{ action: string; qtype?: string; domain?: string }>;
return (
<>
<Form.Item label="Rewrite network">
<Select
value={ob.settings.rewriteNetwork}
allowClear
placeholder="(unchanged)"
onChange={(v) => { ob.settings.rewriteNetwork = v; refresh(); }}
options={['udp', 'tcp'].map((x) => ({ value: x, label: x }))}
/>
</Form.Item>
<Form.Item label="Rewrite address">
<Input
value={ob.settings.rewriteAddress || ''}
placeholder="(unchanged) e.g. 1.1.1.1"
onChange={(e) => { ob.settings.rewriteAddress = e.target.value; refresh(); }}
/>
</Form.Item>
<Form.Item label="Rewrite port">
<InputNumber
value={ob.settings.rewritePort || undefined}
min={0}
max={65535}
style={{ width: '100%' }}
placeholder="(unchanged)"
onChange={(v) => { ob.settings.rewritePort = Number(v) || 0; refresh(); }}
/>
</Form.Item>
<Form.Item label="User level">
<InputNumber
value={ob.settings.userLevel || 0}
min={0}
style={{ width: '100%' }}
onChange={(v) => { ob.settings.userLevel = Number(v) || 0; refresh(); }}
/>
</Form.Item>
<Form.Item label="Rules">
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
onClick={() => { (ob.settings.rules || (ob.settings.rules = [])).push(new Outbound.DNSRule()); refresh(); }}
/>
</Form.Item>
{rules.map((rule, index) => (
<div key={index}>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<div className="item-heading">
<span>Rule {index + 1}</span>
<DeleteOutlined
className="danger-icon"
onClick={() => { (ob.settings.rules as unknown[]).splice(index, 1); refresh(); }}
/>
</div>
</Form.Item>
<Form.Item label="Action">
<Select
value={rule.action}
onChange={(v) => { rule.action = v; refresh(); }}
options={(DNSRuleActions as string[]).map((a) => ({ value: a, label: a }))}
/>
</Form.Item>
<Form.Item label="QType">
<Input
value={rule.qtype}
placeholder="1,3,23-24"
onChange={(e) => { rule.qtype = e.target.value; refresh(); }}
/>
</Form.Item>
<Form.Item label={t('domainName')}>
<Input
value={rule.domain}
placeholder="domain:example.com"
onChange={(e) => { rule.domain = e.target.value; refresh(); }}
/>
</Form.Item>
</div>
))}
</>
);
}
function WireguardFields({ ob, refresh, regenerate, t }: TFieldProps & { regenerate: () => void }) {
const peers = (ob.settings.peers || []) as Array<{ endpoint?: string; publicKey?: string; psk?: string; allowedIPs?: string[]; keepAlive?: number }>;
return (
<>
<Form.Item label={t('pages.inbounds.address')}>
<Input
value={ob.settings.address || ''}
onChange={(e) => { ob.settings.address = e.target.value; refresh(); }}
/>
</Form.Item>
<Form.Item
label={
<>
{t('pages.inbounds.privatekey')}
<SyncOutlined className="random-icon" onClick={regenerate} />
</>
}
>
<Input
value={ob.settings.secretKey || ''}
onChange={(e) => { ob.settings.secretKey = e.target.value; refresh(); }}
/>
</Form.Item>
<Form.Item label={t('pages.inbounds.publicKey')}>
<Input value={ob.settings.pubKey || ''} disabled />
</Form.Item>
<Form.Item label="Domain strategy">
<Select
value={ob.settings.domainStrategy || ''}
onChange={(v) => { ob.settings.domainStrategy = v; refresh(); }}
options={['', ...(WireguardDomainStrategy as string[])].map((x) => ({ value: x, label: x || `(${t('none')})` }))}
/>
</Form.Item>
<Form.Item label="MTU">
<InputNumber value={ob.settings.mtu || 0} min={0} onChange={(v) => { ob.settings.mtu = Number(v) || 0; refresh(); }} />
</Form.Item>
<Form.Item label="Workers">
<InputNumber value={ob.settings.workers || 0} min={0} onChange={(v) => { ob.settings.workers = Number(v) || 0; refresh(); }} />
</Form.Item>
<Form.Item label="No-kernel TUN">
<Switch checked={!!ob.settings.noKernelTun} onChange={(v) => { ob.settings.noKernelTun = v; refresh(); }} />
</Form.Item>
<Form.Item label="Reserved">
<Input value={ob.settings.reserved || ''} onChange={(e) => { ob.settings.reserved = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label="Peers">
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
onClick={() => { (ob.settings.peers || (ob.settings.peers = [])).push(new Outbound.WireguardSettings.Peer()); refresh(); }}
/>
</Form.Item>
{peers.map((peer, index) => (
<div key={index}>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<div className="item-heading">
<span>Peer {index + 1}</span>
{peers.length > 1 && (
<DeleteOutlined
className="danger-icon"
onClick={() => { (ob.settings.peers as unknown[]).splice(index, 1); refresh(); }}
/>
)}
</div>
</Form.Item>
<Form.Item label="Endpoint">
<Input value={peer.endpoint} onChange={(e) => { peer.endpoint = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label={t('pages.inbounds.publicKey')}>
<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">
{(peer.allowedIPs || []).map((ip, idx) => (
<Space.Compact key={idx} block style={{ marginBottom: 4 }}>
<Input
value={ip}
onChange={(e) => { peer.allowedIPs![idx] = e.target.value; refresh(); }}
/>
{(peer.allowedIPs || []).length > 1 && (
<InputAddon onClick={() => { peer.allowedIPs!.splice(idx, 1); refresh(); }}>
<MinusOutlined />
</InputAddon>
)}
</Space.Compact>
))}
<Button
size="small"
icon={<PlusOutlined />}
onClick={() => { (peer.allowedIPs = peer.allowedIPs || []).push(''); refresh(); }}
/>
</Form.Item>
<Form.Item label="Keep alive">
<InputNumber value={peer.keepAlive || 0} min={0} onChange={(v) => { peer.keepAlive = Number(v) || 0; refresh(); }} />
</Form.Item>
</div>
))}
</>
);
}
function VMessVLessFields({ ob, refresh, isVMess, isVLESS, t }: TFieldProps & { isVMess: boolean; isVLESS: boolean }) {
const rev = ob.settings.reverseSniffing || {};
return (
<>
<Form.Item label="ID">
<Input value={ob.settings.id || ''} onChange={(e) => { ob.settings.id = e.target.value; refresh(); }} />
</Form.Item>
{isVMess && (
<Form.Item label={t('security')}>
<Select
value={ob.settings.security}
onChange={(v) => { ob.settings.security = v; refresh(); }}
options={SECURITY_OPTIONS.map((s) => ({ value: s, label: s }))}
/>
</Form.Item>
)}
{isVLESS && (
<Form.Item label={t('encryption')}>
<Input
value={ob.settings.encryption || ''}
onChange={(e) => { ob.settings.encryption = e.target.value; refresh(); }}
/>
</Form.Item>
)}
{isVLESS && (
<Form.Item label="Reverse tag">
<Input
value={ob.settings.reverseTag || ''}
placeholder="optional"
onChange={(e) => { ob.settings.reverseTag = e.target.value; refresh(); }}
/>
</Form.Item>
)}
{isVLESS && ob.settings.reverseTag && (
<>
<Form.Item label="Reverse Sniffing">
<Switch checked={!!rev.enabled} onChange={(v) => { rev.enabled = v; refresh(); }} />
</Form.Item>
{rev.enabled && (
<>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<Checkbox.Group
className="sniffing-options"
value={rev.destOverride || []}
onChange={(v) => { rev.destOverride = v as string[]; refresh(); }}
options={Object.entries(SNIFFING_OPTION).map(([label, value]) => ({ label, value: value as string }))}
/>
</Form.Item>
<Form.Item label="Metadata Only">
<Switch checked={!!rev.metadataOnly} onChange={(v) => { rev.metadataOnly = v; refresh(); }} />
</Form.Item>
<Form.Item label="Route Only">
<Switch checked={!!rev.routeOnly} onChange={(v) => { rev.routeOnly = v; refresh(); }} />
</Form.Item>
<Form.Item label="IPs Excluded">
<Select
mode="tags"
value={rev.ipsExcluded || []}
tokenSeparators={[',']}
placeholder="IP/CIDR/geoip:*/ext:*"
style={{ width: '100%' }}
onChange={(v) => { rev.ipsExcluded = v as string[]; refresh(); }}
/>
</Form.Item>
<Form.Item label="Domains Excluded">
<Select
mode="tags"
value={rev.domainsExcluded || []}
tokenSeparators={[',']}
placeholder="domain:*/ext:*"
style={{ width: '100%' }}
onChange={(v) => { rev.domainsExcluded = v as string[]; refresh(); }}
/>
</Form.Item>
</>
)}
</>
)}
{ob.canEnableTlsFlow() && (
<Form.Item label="Flow">
<Select
value={ob.settings.flow || ''}
onChange={(v) => { ob.settings.flow = v; refresh(); }}
options={[{ value: '', label: t('none') }, ...FLOW_OPTIONS.map((k) => ({ value: k, label: k }))]}
/>
</Form.Item>
)}
</>
);
}
function StreamFields({ ob, refresh, streamNetworkChange, isHysteria, t }: TFieldProps & { streamNetworkChange: (next: string) => void; isHysteria: boolean }) {
const networks = isHysteria ? [...NETWORKS, 'hysteria'] : NETWORKS;
return (
<>
<Form.Item label={t('transmission')}>
<Select
value={ob.stream.network}
onChange={streamNetworkChange}
options={networks.map((net) => ({ value: net, label: NETWORK_LABELS[net] || net }))}
/>
</Form.Item>
{ob.stream.network === 'tcp' && (
<>
<Form.Item label={`HTTP ${t('camouflage')}`}>
<Switch
checked={ob.stream.tcp.type === 'http'}
onChange={(checked) => { ob.stream.tcp.type = checked ? 'http' : 'none'; refresh(); }}
/>
</Form.Item>
{ob.stream.tcp.type === 'http' && (
<>
<Form.Item label={t('host')}>
<Input value={ob.stream.tcp.host || ''} onChange={(e) => { ob.stream.tcp.host = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label={t('path')}>
<Input value={ob.stream.tcp.path || ''} onChange={(e) => { ob.stream.tcp.path = e.target.value; refresh(); }} />
</Form.Item>
</>
)}
</>
)}
{ob.stream.network === 'kcp' && (
<>
{(
[
['mtu', 'MTU', 0],
['tti', 'TTI (ms)', 0],
['upCap', 'Uplink (MB/s)', 0],
['downCap', 'Downlink (MB/s)', 0],
['cwndMultiplier', 'CWND multiplier', 1],
['maxSendingWindow', 'Max sending window', 0],
] as const
).map(([field, label, min]) => (
<Form.Item key={field} label={label}>
<InputNumber
value={ob.stream.kcp[field] ?? 0}
min={min}
onChange={(v) => { ob.stream.kcp[field] = Number(v) || 0; refresh(); }}
/>
</Form.Item>
))}
</>
)}
{ob.stream.network === 'ws' && (
<>
<Form.Item label={t('host')}>
<Input value={ob.stream.ws.host || ''} onChange={(e) => { ob.stream.ws.host = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label={t('path')}>
<Input value={ob.stream.ws.path || ''} onChange={(e) => { ob.stream.ws.path = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label="Heartbeat (s)">
<InputNumber
value={ob.stream.ws.heartbeatPeriod || 0}
min={0}
onChange={(v) => { ob.stream.ws.heartbeatPeriod = Number(v) || 0; refresh(); }}
/>
</Form.Item>
</>
)}
{ob.stream.network === 'grpc' && (
<>
<Form.Item label="Service name">
<Input value={ob.stream.grpc.serviceName || ''} onChange={(e) => { ob.stream.grpc.serviceName = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label="Authority">
<Input value={ob.stream.grpc.authority || ''} onChange={(e) => { ob.stream.grpc.authority = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label="Multi mode">
<Switch checked={!!ob.stream.grpc.multiMode} onChange={(v) => { ob.stream.grpc.multiMode = v; refresh(); }} />
</Form.Item>
</>
)}
{ob.stream.network === 'httpupgrade' && (
<>
<Form.Item label={t('host')}>
<Input value={ob.stream.httpupgrade.host || ''} onChange={(e) => { ob.stream.httpupgrade.host = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label={t('path')}>
<Input value={ob.stream.httpupgrade.path || ''} onChange={(e) => { ob.stream.httpupgrade.path = e.target.value; refresh(); }} />
</Form.Item>
</>
)}
{ob.stream.network === 'xhttp' && <XhttpFields ob={ob} refresh={refresh} t={t} />}
{ob.stream.network === 'hysteria' && <HysteriaTransportFields ob={ob} refresh={refresh} />}
</>
);
}
function XhttpFields({ ob, refresh, t }: TFieldProps) {
const xh = ob.stream.xhttp;
return (
<>
<Form.Item label={t('host')}>
<Input value={xh.host || ''} onChange={(e) => { xh.host = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label={t('path')}>
<Input value={xh.path || ''} onChange={(e) => { xh.path = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label={t('pages.inbounds.stream.tcp.requestHeader')}>
<Button size="small" icon={<PlusOutlined />} onClick={() => { xh.addHeader('', ''); refresh(); }} />
</Form.Item>
<Form.Item wrapperCol={{ span: 24 }}>
{(xh.headers as Array<{ name: string; value: string }>).map((header, idx) => (
<Space.Compact key={idx} block className="mb-8">
<InputAddon>{`${idx + 1}`}</InputAddon>
<Input
value={header.name}
placeholder="Name"
onChange={(e) => { header.name = e.target.value; refresh(); }}
/>
<Input
value={header.value}
placeholder="Value"
onChange={(e) => { header.value = e.target.value; refresh(); }}
/>
<Button icon={<MinusOutlined />} onClick={() => { xh.removeHeader(idx); refresh(); }} />
</Space.Compact>
))}
</Form.Item>
<Form.Item label="Mode">
<Select
value={xh.mode}
onChange={(v) => { xh.mode = v; refresh(); }}
options={Object.values(MODE_OPTION).map((m) => ({ value: m as string, label: m as string }))}
/>
</Form.Item>
{xh.mode === 'packet-up' && (
<>
<Form.Item label="Max Upload Size (Byte)">
<Input value={xh.scMaxEachPostBytes} onChange={(e) => { xh.scMaxEachPostBytes = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label="Min Upload Interval (Ms)">
<Input value={xh.scMinPostsIntervalMs} onChange={(e) => { xh.scMinPostsIntervalMs = e.target.value; refresh(); }} />
</Form.Item>
</>
)}
<Form.Item label="Padding Bytes">
<Input value={xh.xPaddingBytes} onChange={(e) => { xh.xPaddingBytes = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label="Padding Obfs Mode">
<Switch checked={!!xh.xPaddingObfsMode} onChange={(v) => { xh.xPaddingObfsMode = v; refresh(); }} />
</Form.Item>
{xh.xPaddingObfsMode && (
<>
<Form.Item label="Padding Key">
<Input value={xh.xPaddingKey} placeholder="x_padding" onChange={(e) => { xh.xPaddingKey = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label="Padding Header">
<Input value={xh.xPaddingHeader} placeholder="X-Padding" onChange={(e) => { xh.xPaddingHeader = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label="Padding Placement">
<Select
value={xh.xPaddingPlacement || ''}
onChange={(v) => { xh.xPaddingPlacement = v; refresh(); }}
options={[
{ value: '', label: 'Default (queryInHeader)' },
{ value: 'queryInHeader', label: 'queryInHeader' },
{ value: 'header', label: 'header' },
{ value: 'cookie', label: 'cookie' },
{ value: 'query', label: 'query' },
]}
/>
</Form.Item>
<Form.Item label="Padding Method">
<Select
value={xh.xPaddingMethod || ''}
onChange={(v) => { xh.xPaddingMethod = v; refresh(); }}
options={[
{ value: '', label: 'Default (repeat-x)' },
{ value: 'repeat-x', label: 'repeat-x' },
{ value: 'tokenish', label: 'tokenish' },
]}
/>
</Form.Item>
</>
)}
<Form.Item label="Uplink HTTP Method">
<Select
value={xh.uplinkHTTPMethod || ''}
onChange={(v) => { xh.uplinkHTTPMethod = v; refresh(); }}
options={[
{ value: '', label: 'Default (POST)' },
{ value: 'POST', label: 'POST' },
{ value: 'PUT', label: 'PUT' },
{ value: 'GET', label: 'GET (packet-up only)', disabled: xh.mode !== 'packet-up' },
]}
/>
</Form.Item>
<Form.Item label="Session Placement">
<Select
value={xh.sessionPlacement || ''}
onChange={(v) => { xh.sessionPlacement = v; refresh(); }}
options={[
{ value: '', label: 'Default (path)' },
{ value: 'path', label: 'path' },
{ value: 'header', label: 'header' },
{ value: 'cookie', label: 'cookie' },
{ value: 'query', label: 'query' },
]}
/>
</Form.Item>
{xh.sessionPlacement && xh.sessionPlacement !== 'path' && (
<Form.Item label="Session Key">
<Input value={xh.sessionKey} placeholder="x_session" onChange={(e) => { xh.sessionKey = e.target.value; refresh(); }} />
</Form.Item>
)}
<Form.Item label="Sequence Placement">
<Select
value={xh.seqPlacement || ''}
onChange={(v) => { xh.seqPlacement = v; refresh(); }}
options={[
{ value: '', label: 'Default (path)' },
{ value: 'path', label: 'path' },
{ value: 'header', label: 'header' },
{ value: 'cookie', label: 'cookie' },
{ value: 'query', label: 'query' },
]}
/>
</Form.Item>
{xh.seqPlacement && xh.seqPlacement !== 'path' && (
<Form.Item label="Sequence Key">
<Input value={xh.seqKey} placeholder="x_seq" onChange={(e) => { xh.seqKey = e.target.value; refresh(); }} />
</Form.Item>
)}
{xh.mode === 'packet-up' && (
<Form.Item label="Uplink Data Placement">
<Select
value={xh.uplinkDataPlacement || ''}
onChange={(v) => { xh.uplinkDataPlacement = v; refresh(); }}
options={[
{ value: '', label: 'Default (body)' },
{ value: 'body', label: 'body' },
{ value: 'header', label: 'header' },
{ value: 'cookie', label: 'cookie' },
{ value: 'query', label: 'query' },
]}
/>
</Form.Item>
)}
{xh.mode === 'packet-up' && xh.uplinkDataPlacement && xh.uplinkDataPlacement !== 'body' && (
<>
<Form.Item label="Uplink Data Key">
<Input value={xh.uplinkDataKey} placeholder="x_data" onChange={(e) => { xh.uplinkDataKey = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label="Uplink Chunk Size">
<InputNumber
value={xh.uplinkChunkSize}
min={0}
placeholder="0 (unlimited)"
onChange={(v) => { xh.uplinkChunkSize = Number(v) || 0; refresh(); }}
/>
</Form.Item>
</>
)}
{(xh.mode === 'stream-up' || xh.mode === 'stream-one') && (
<Form.Item label="No gRPC Header">
<Switch checked={!!xh.noGRPCHeader} onChange={(v) => { xh.noGRPCHeader = v; refresh(); }} />
</Form.Item>
)}
<Form.Item label="XMUX">
<Switch checked={!!xh.enableXmux} onChange={(v) => { xh.enableXmux = v; refresh(); }} />
</Form.Item>
{xh.enableXmux && (
<>
{!xh.xmux.maxConnections && (
<Form.Item label="Max Concurrency">
<Input value={xh.xmux.maxConcurrency} onChange={(e) => { xh.xmux.maxConcurrency = e.target.value; refresh(); }} />
</Form.Item>
)}
{!xh.xmux.maxConcurrency && (
<Form.Item label="Max Connections">
<Input value={xh.xmux.maxConnections} onChange={(e) => { xh.xmux.maxConnections = e.target.value; refresh(); }} />
</Form.Item>
)}
<Form.Item label="Max Reuse Times">
<Input value={xh.xmux.cMaxReuseTimes} onChange={(e) => { xh.xmux.cMaxReuseTimes = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label="Max Request Times">
<Input value={xh.xmux.hMaxRequestTimes} onChange={(e) => { xh.xmux.hMaxRequestTimes = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label="Max Reusable Secs">
<Input value={xh.xmux.hMaxReusableSecs} onChange={(e) => { xh.xmux.hMaxReusableSecs = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label="Keep Alive Period">
<InputNumber
value={xh.xmux.hKeepAlivePeriod}
min={0}
onChange={(v) => { xh.xmux.hKeepAlivePeriod = Number(v) || 0; refresh(); }}
/>
</Form.Item>
</>
)}
</>
);
}
function HysteriaTransportFields({ ob, refresh }: FieldProps) {
const h = ob.stream.hysteria;
return (
<>
<Form.Item label="Auth password">
<Input value={h.auth || ''} onChange={(e) => { h.auth = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label="Congestion">
<Select
value={h.congestion || ''}
onChange={(v) => { h.congestion = v; refresh(); }}
options={[
{ value: '', label: 'BBR (auto)' },
{ value: 'brutal', label: 'Brutal' },
]}
/>
</Form.Item>
<Form.Item label="Upload">
<Input value={h.up} placeholder="100 mbps" onChange={(e) => { h.up = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label="Download">
<Input value={h.down} placeholder="100 mbps" onChange={(e) => { h.down = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label="UDP hop port">
<Input value={h.udphopPort} placeholder="1145-1919" onChange={(e) => { h.udphopPort = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label="Max idle (s)">
<InputNumber value={h.maxIdleTimeout} min={4} max={120} onChange={(v) => { h.maxIdleTimeout = Number(v) || 0; refresh(); }} />
</Form.Item>
<Form.Item label="Keep alive (s)">
<InputNumber value={h.keepAlivePeriod} min={2} max={60} onChange={(v) => { h.keepAlivePeriod = Number(v) || 0; refresh(); }} />
</Form.Item>
<Form.Item label="Disable Path MTU">
<Switch checked={!!h.disablePathMTUDiscovery} onChange={(v) => { h.disablePathMTUDiscovery = v; refresh(); }} />
</Form.Item>
</>
);
}
function TlsFields({ ob, refresh, t }: TFieldProps) {
return (
<>
<Form.Item label={t('security')}>
<Radio.Group
value={ob.stream.security}
buttonStyle="solid"
onChange={(e) => { ob.stream.security = e.target.value; refresh(); }}
>
<Radio.Button value="none">{t('none')}</Radio.Button>
<Radio.Button value="tls">TLS</Radio.Button>
{ob.canEnableReality() && <Radio.Button value="reality">Reality</Radio.Button>}
</Radio.Group>
</Form.Item>
{ob.stream.isTls && (
<>
<Form.Item label="SNI">
<Input value={ob.stream.tls.serverName} placeholder="server name" onChange={(e) => { ob.stream.tls.serverName = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label="uTLS">
<Select
value={ob.stream.tls.fingerprint || ''}
onChange={(v) => { ob.stream.tls.fingerprint = v; refresh(); }}
options={[{ value: '', label: t('none') }, ...UTLS_OPTIONS.map((k) => ({ value: k, label: k }))]}
/>
</Form.Item>
<Form.Item label="ALPN">
<Select
mode="multiple"
value={ob.stream.tls.alpn || []}
onChange={(v) => { ob.stream.tls.alpn = v; refresh(); }}
options={ALPN_OPTIONS.map((alpn) => ({ value: alpn, label: alpn }))}
/>
</Form.Item>
<Form.Item label="ECH">
<Input value={ob.stream.tls.echConfigList} onChange={(e) => { ob.stream.tls.echConfigList = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label="Verify peer name">
<Input value={ob.stream.tls.verifyPeerCertByName} placeholder="cloudflare-dns.com" onChange={(e) => { ob.stream.tls.verifyPeerCertByName = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label="Pinned SHA256">
<Input value={ob.stream.tls.pinnedPeerCertSha256} placeholder="base64 SHA256" onChange={(e) => { ob.stream.tls.pinnedPeerCertSha256 = e.target.value; refresh(); }} />
</Form.Item>
</>
)}
{ob.stream.isReality && (
<>
<Form.Item label="SNI">
<Input value={ob.stream.reality.serverName} onChange={(e) => { ob.stream.reality.serverName = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label="uTLS">
<Select
value={ob.stream.reality.fingerprint}
onChange={(v) => { ob.stream.reality.fingerprint = v; refresh(); }}
options={UTLS_OPTIONS.map((k) => ({ value: k, label: k }))}
/>
</Form.Item>
<Form.Item label="Short ID">
<Input value={ob.stream.reality.shortId} onChange={(e) => { ob.stream.reality.shortId = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label="SpiderX">
<Input value={ob.stream.reality.spiderX} onChange={(e) => { ob.stream.reality.spiderX = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label={t('pages.inbounds.publicKey')}>
<Input.TextArea
value={ob.stream.reality.publicKey}
autoSize={{ minRows: 2 }}
onChange={(e) => { ob.stream.reality.publicKey = e.target.value; refresh(); }}
/>
</Form.Item>
<Form.Item label="mldsa65 verify">
<Input.TextArea
value={ob.stream.reality.mldsa65Verify}
autoSize={{ minRows: 2 }}
onChange={(e) => { ob.stream.reality.mldsa65Verify = e.target.value; refresh(); }}
/>
</Form.Item>
</>
)}
</>
);
}
function SockoptFields({ ob, refresh }: FieldProps) {
return (
<>
<Form.Item label="Sockopts">
<Switch checked={!!ob.stream.sockoptSwitch} onChange={(v) => { ob.stream.sockoptSwitch = v; refresh(); }} />
</Form.Item>
{ob.stream.sockoptSwitch && (
<>
<Form.Item label="Dialer proxy">
<Input value={ob.stream.sockopt.dialerProxy || ''} onChange={(e) => { ob.stream.sockopt.dialerProxy = e.target.value; refresh(); }} />
</Form.Item>
<Form.Item label="Address+Port strategy">
<Select
value={ob.stream.sockopt.addressPortStrategy}
onChange={(v) => { ob.stream.sockopt.addressPortStrategy = v; refresh(); }}
options={Object.values(Address_Port_Strategy).map((k) => ({ value: k as string, label: k as string }))}
/>
</Form.Item>
<Form.Item label="Keep alive interval">
<InputNumber
value={ob.stream.sockopt.tcpKeepAliveInterval}
min={0}
onChange={(v) => { ob.stream.sockopt.tcpKeepAliveInterval = Number(v) || 0; refresh(); }}
/>
</Form.Item>
<Form.Item label="TCP Fast Open">
<Switch checked={!!ob.stream.sockopt.tcpFastOpen} onChange={(v) => { ob.stream.sockopt.tcpFastOpen = v; refresh(); }} />
</Form.Item>
<Form.Item label="Multipath TCP">
<Switch checked={!!ob.stream.sockopt.tcpMptcp} onChange={(v) => { ob.stream.sockopt.tcpMptcp = v; refresh(); }} />
</Form.Item>
<Form.Item label="Penetrate">
<Switch checked={!!ob.stream.sockopt.penetrate} onChange={(v) => { ob.stream.sockopt.penetrate = v; refresh(); }} />
</Form.Item>
<Form.Item label="Mark (fwmark)">
<InputNumber
value={ob.stream.sockopt.mark}
min={0}
onChange={(v) => { ob.stream.sockopt.mark = Number(v) || 0; refresh(); }}
/>
</Form.Item>
<Form.Item label="Interface">
<Input value={ob.stream.sockopt.interfaceName} onChange={(e) => { ob.stream.sockopt.interfaceName = e.target.value; refresh(); }} />
</Form.Item>
</>
)}
</>
);
}
function MuxFields({ ob, refresh, t }: TFieldProps) {
return (
<>
<Form.Item label={t('pages.settings.mux')}>
<Switch checked={!!ob.mux.enabled} onChange={(v) => { ob.mux.enabled = v; refresh(); }} />
</Form.Item>
{ob.mux.enabled && (
<>
<Form.Item label="Concurrency">
<InputNumber
value={ob.mux.concurrency}
min={-1}
max={1024}
onChange={(v) => { ob.mux.concurrency = Number(v) || 0; refresh(); }}
/>
</Form.Item>
<Form.Item label="xudp concurrency">
<InputNumber
value={ob.mux.xudpConcurrency}
min={-1}
max={1024}
onChange={(v) => { ob.mux.xudpConcurrency = Number(v) || 0; refresh(); }}
/>
</Form.Item>
<Form.Item label="xudp UDP 443">
<Select
value={ob.mux.xudpProxyUDP443}
onChange={(v) => { ob.mux.xudpProxyUDP443 = v; refresh(); }}
options={['reject', 'allow', 'skip'].map((x) => ({ value: x, label: x }))}
/>
</Form.Item>
</>
)}
</>
);
}