Frontend dev tooling (Husky, lint-staged, MSW, Storybook) + full React Hook Form migration (#5859)

* chore(frontend): add husky + lint-staged pre-commit gate

Wire a local pre-commit gate that runs eslint --fix on staged
frontend TypeScript via lint-staged. Because the only package.json
lives in frontend/ while the git root is one level up, the prepare
script installs husky hooks at frontend/.husky from the repo root
(cd .. && husky frontend/.husky), and the pre-commit hook cd's into
frontend/ before invoking lint-staged so node_modules resolves.

* test(frontend): add MSW request mocking

Add Mock Service Worker so tests can exercise the real http-init.ts
request pipeline (CSRF acquisition, 403 refetch-and-retry, body
parsing) instead of only stubbing HttpUtil. A node setupServer is
started for the vitest unit project with onUnhandledRequest bypass so
the existing HttpUtil spies and 55 component tests are untouched; the
browser worker is copied to public/ for Storybook and dev use.

* chore(frontend): add Storybook + component stories

Set up Storybook 10 on the React-Vite builder (compatible with the
pinned Vite 8.1.3 and React 19). The preview decorator mirrors the
vitest component harness: an Ant Design ConfigProvider with a
light/dark toolbar toggle and an en-US i18next instance. main.ts
neutralizes the app vite config bits that do not belong in a component
workshop (the three-entry rollup input, renderBuiltUrl, and the shared
dist outDir) so build-storybook can never clobber internal/web/dist.
Seeds stories across the presentational library (viz, ui, clients,
feedback). build-storybook is a local tool and is not wired into the
CI gate.

* feat(frontend): add React Hook Form primitives

Introduce the shared RHF layer that AntD inputs bind through, ahead of
migrating the forms off Ant Design's Form store:
- FormField wraps a Controller in an Ant Design Form.Item shell,
  reconciling the value/onChange shapes of Input, Switch, InputNumber,
  Select and friends via normalizeAntdOnChange, with input/output
  transforms and Zod-issue-key error messages resolved through t().
- useZodForm wires zodResolver (Zod 4) with the AntD-matching modes
  (validate on submit, then live) and shouldUnregister false so hidden
  and unmounted-tab fields keep their values.
- rhfZodValidate covers the rare per-field rule sites.
Covered by a FormField test exercising normalization, transforms, and
resolver error surfacing.

* refactor(frontend): migrate Pattern-B leaf forms to React Hook Form

Move the controlled-useState leaf forms onto RHF via the FormField
primitive, keeping Ant Design components and each form's exact submit
behaviour (same safeParse, same toast on the first Zod issue, same
payload building):
- clients: ClientBulkAdjustModal, BulkAddToGroupModal, ClientBulkAddModal
- xray: RuleFormModal, BalancerFormModal, WarpModal, NordModal

Multi-control widgets that don't fit a single input (inbound dual
select, subId regen, expiry branches, the balancer tag warning) stay as
explicit Controller/setValue. Derived visibility now reads live values
through useWatch. FormField gains a required prop so migrated fields keep
their required-asterisk affordance.

Settings tabs are intentionally excluded: they are control-panel
components that live-patch a parent AllSetting via SettingListItem, not
Ant Design Form submit-forms.

* refactor(frontend): migrate LoginPage to React Hook Form

Replace the Ant Design Form store + antdRule per-field validation with
useForm + FormField. The AntD Form stays as the layout/submit wrapper,
now driving methods.handleSubmit(onSubmit) via onFinish. Username and
password validate through rhfZodValidate(LoginFormSchema.shape.*); the
two-factor field keeps its conditional required rule (only registered
when 2FA is enabled). Submit posts the same values to /login.

* refactor(frontend): migrate ClientFormModal to React Hook Form

Move the client add/edit form off controlled useState onto RHF while
preserving exact submit behaviour (same ClientFormSchema /
ClientCreateFormSchema safeParse, same toast, same payload + attach/
detach diff + external-links build). expiryDate is stored as an epoch
number (never a Dayjs) to survive RHF's value cloning, converted at the
DateTimePicker boundary. externalLinks uses useFieldArray with stable
ids. inboundIds and the derived show*/ss2022 visibility read live via
useWatch. Space.Compact button-group widgets stay manual Controllers so
the joined borders keep working.

* refactor(frontend): migrate Node and DNS modals to React Hook Form

Both are self-contained Pattern-A forms (no shared fragments). Replace
Form.useForm with useForm + FormProvider, Form.useWatch with useWatch,
setFieldValue with setValue, and partial validateFields([...]) with
methods.trigger([...]). Per-field antdRule becomes rhfZodValidate rules;
the Node scheme->tlsVerify cascade moves to FormField onAfterChange; the
DNS domains/expectIPs/unexpectIPs string arrays are driven by
useWatch + setValue. Submit runs through handleSubmit on the modal OK
button, preserving each form's exact validation, payload build, and
save/onConfirm behaviour.

* refactor(frontend): migrate HostFormModal to React Hook Form

The host external-proxy editor's outer form moves to useForm +
FormProvider. Security/tab visibility reads via useWatch; the three
json-form editors (HostMuxForm/HostSockoptForm/HostFinalMaskForm) are
bound as value/onChange black boxes through a Controller (their own
internal forms are unchanged). remark/inboundId keep their validation
via rhfZodValidate; submit runs through handleSubmit and builds the
same payload (isDisabled = !enable) and save call.

* refactor(frontend): migrate OutboundFormModal + fragments to React Hook Form

Move the outbound form cluster off Ant Design's Form store onto RHF.
The parent uses useForm + FormProvider with a watch() subscription for
the protocol reseed cascade and setValue-based network/security/xmux
cascades; the JSON<->Basic bridge and the formValuesToWirePayload
submit are preserved exactly. Every outbound transport/protocol/security
fragment now binds through FormField/useWatch via context.

The shared config editors stay untouched and are bound through small
value/onChange adapters (src/lib/xray/forms/fields: FinalMaskField,
SniffingField, SockoptCustomField) via Controller; HeaderMapEditor binds
directly. The host json-form wrappers that reuse the outbound MuxForm/
SockoptForm (HostMuxForm, HostSockoptForm, OutboundSubtreeJsonForm) move
to a local RHF provider to match. Outbound render/link tests pass
unchanged.

* refactor(frontend): migrate InboundFormModal + fragments to React Hook Form

Move the inbound add/edit form (the largest form in the panel) and its
transport/protocol/security fragments off Ant Design's Form store onto
RHF, mirroring the outbound migration. The parent uses useForm +
FormProvider with a watch() subscription for the protocol reseed
cascade (type==='change' guard so programmatic resets don't reseed) and
setValue-based network/security cascades; useSecurityActions drives the
TLS/Reality keypair + scan through setValue. Hidden pass-through
Form.Items are dropped (their values ride in the reset object and
survive via shouldUnregister:false), so getValues() still returns the
settings.clients subtree untouched. accounts / certificates / tun lists
use useFieldArray; the shared FinalMask/Sniffing/Sockopt editors bind
through the value/onChange adapters.

Submit keeps the manual InboundFormSchema.safeParse + formatInboundValidation
toast + formValuesToWirePayload exactly. The golden link/full fixtures
pass byte-for-byte, confirming identical wire output. inbound-form-blocks
test harness rewritten from a Form.useForm harness to an RHF provider.

* refactor(frontend): retire antdRule; document the RHF form pattern

All forms now build on React Hook Form, so the AntD-Form Zod adapter
antdRule (src/utils/zodForm.ts) has no remaining callers — remove it.
Update frontend/CLAUDE.md: forms use useZodForm + FormField from
components/form/rhf with zodResolver/rhfZodValidate validation; AntD
<Form> is layout-only; the shared FinalMask/Sniffing/Sockopt editors
stay AntD islands wrapped as value/onChange adapters bound via a
Controller.

* chore(frontend): cover esbuild in the allowScripts allowlist

esbuild (pulled in transitively by Vite/Vitest/Storybook) ships a
postinstall that npm's allow-scripts flags as uncovered on every
install. Its platform binary is delivered through the @esbuild/<platform>
optionalDependencies, so the postinstall isn't needed here; deny it like
the other entries to silence the warning.

* fix(frontend): restore label layout in Sniffing/FinalMask field adapters

The value/onChange adapters that wrap the shared SniffingFields and
FinalMaskForm editors put them in their own isolated AntD Form, but that
Form was missing the label layout the fields used to inherit from the
inbound/outbound parent form. Their labels rendered full-width instead
of the compact right-aligned column, so the Sniffing tab and the TCP
Masks / QUIC Params sections looked broken. Give both adapter forms the
same colon=false, labelCol/wrapperCol span 8/14, labelWrap layout.

* ci: add least-privilege permissions to Docs CI workflow

The docs-ci workflow had no explicit permissions block, so it inherited
the repository default for GITHUB_TOKEN. The build job only checks out
and builds the docs, so restrict it to contents: read, resolving the
CodeQL actions/missing-workflow-permissions alert.
This commit is contained in:
Sanaei
2026-07-08 13:28:37 +02:00
committed by GitHub
parent 8ee79cf447
commit 61e12e4c29
98 changed files with 9496 additions and 5089 deletions
+226 -209
View File
@@ -11,18 +11,22 @@ import {
DeploymentUnitOutlined,
RocketOutlined,
} from '@ant-design/icons';
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import type { HostRecord } from '@/api/queries/useHostsQuery';
import type { HostFormValues } from '@/schemas/api/host';
import { HostFormSchema, type HostFormValues } from '@/schemas/api/host';
import type { InboundOption } from '@/schemas/client';
import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { useNodesQuery } from '@/api/queries/useNodesQuery';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from '@/pages/settings/catTabLabel';
import { HostFinalMaskForm, HostMuxForm, HostSockoptForm } from './json-forms';
// inboundId is optional in the form so a new host starts unselected (the Select
// shows its placeholder instead of 0); the required rule enforces it on submit.
/*
* inboundId is optional in the form so a new host starts unselected (the Select
* shows its placeholder instead of 0); the required rule enforces it on submit.
*/
type FormShape = Omit<HostFormValues, 'isDisabled' | 'inboundId'> & { enable: boolean; inboundId?: number };
interface HostFormModalProps {
@@ -74,19 +78,21 @@ function defaultsFor(host: HostRecord | null): FormShape {
export default function HostFormModal({ open, mode, host, inboundOptions, save, onOpenChange }: HostFormModalProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [form] = Form.useForm<FormShape>();
const methods = useForm<FormShape>({ defaultValues: defaultsFor(host) });
// Drive conditional field visibility off the selected security, like the
// legacy externalProxy form: same/none inherit fully and hide every TLS/cert
// field; reality shows only the reality-relevant subset (its keys are
// inherited from the inbound); tls shows the full TLS override set.
const security = (Form.useWatch('security', form) ?? 'same') as string;
/*
* Drive conditional field visibility off the selected security, like the
* legacy externalProxy form: same/none inherit fully and hide every TLS/cert
* field; reality shows only the reality-relevant subset (its keys are
* inherited from the inbound); tls shows the full TLS override set.
*/
const security = (useWatch({ control: methods.control, name: 'security' }) ?? 'same') as string;
const showTls = security === 'tls' || security === 'reality';
const showTlsExtras = security === 'tls';
useEffect(() => {
if (open) form.setFieldsValue(defaultsFor(host));
}, [open, host, form]);
if (open) methods.reset(defaultsFor(host));
}, [open, host, methods]);
const { nodes } = useNodesQuery();
@@ -108,13 +114,7 @@ export default function HostFormModal({ open, mode, host, inboundOptions, save,
const alpnOptions = useMemo(() => Object.values(ALPN_OPTION).map((v) => ({ value: v, label: v })), []);
const fpOptions = useMemo(() => Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })), []);
const onOk = async () => {
let values: FormShape;
try {
values = await form.validateFields();
} catch {
return;
}
const onFinish = async (values: FormShape) => {
const { enable, ...rest } = values;
const payload: Partial<HostFormValues> = { ...rest, isDisabled: !enable };
const res = await save(payload);
@@ -130,7 +130,7 @@ export default function HostFormModal({ open, mode, host, inboundOptions, save,
<Modal
open={open}
title={t(mode === 'add' ? 'pages.hosts.addHost' : 'pages.hosts.editHost')}
onOk={onOk}
onOk={methods.handleSubmit(onFinish)}
onCancel={() => onOpenChange(false)}
okText={t('save')}
cancelText={t('cancel')}
@@ -138,198 +138,215 @@ export default function HostFormModal({ open, mode, host, inboundOptions, save,
width={isMobile ? '95vw' : 760}
styles={{ body: { maxHeight: '70vh', overflowY: 'auto', overflowX: 'hidden' } }}
>
<Form
form={form}
colon={false}
labelCol={{ sm: { span: 8 } }}
wrapperCol={{ sm: { span: 14 } }}
labelWrap
initialValues={defaultsFor(host)}
preserve={false}
>
<Tabs
defaultActiveKey="basic"
items={[
{
key: 'basic',
forceRender: true,
label: catTabLabel(<ProfileOutlined />, t('pages.hosts.sections.basic'), isMobile),
children: (
<>
<Form.Item name="remark" label={t('pages.hosts.fields.remark')} tooltip={t('pages.hosts.hints.remark')} rules={[{ required: true, max: 256 }]}>
<Input maxLength={256} />
</Form.Item>
<Form.Item name="serverDescription" label={t('pages.hosts.fields.serverDescription')} tooltip={t('pages.hosts.hints.serverDescription')}>
<Input maxLength={64} />
</Form.Item>
<Form.Item name="inboundId" label={t('pages.hosts.fields.inbound')} rules={[{ required: true }]}>
<Select
options={inboundSelectOptions}
showSearch
optionFilterProp="label"
disabled={mode === 'edit'}
placeholder={t('pages.hosts.selectInbound')}
/>
</Form.Item>
<Form.Item name="address" label={t('pages.hosts.fields.address')} tooltip={t('pages.hosts.hints.address')}>
<Input placeholder="cdn.example.com" />
</Form.Item>
<Form.Item name="port" label={t('pages.hosts.fields.port')} tooltip={t('pages.hosts.hints.port')}>
<InputNumber min={0} max={65535} />
</Form.Item>
<Form.Item name="tags" label={t('pages.hosts.fields.tags')} tooltip={t('pages.hosts.hints.tags')}>
<Select mode="tags" allowClear tokenSeparators={[',']} />
</Form.Item>
<Form.Item name="nodeGuids" label={t('pages.hosts.fields.nodeGuids')} tooltip={t('pages.hosts.hints.nodeGuids')}>
<Select mode="multiple" allowClear options={nodeSelectOptions} optionFilterProp="label" />
</Form.Item>
<Form.Item name="enable" label={t('pages.hosts.fields.enable')} valuePropName="checked">
<Switch />
</Form.Item>
</>
),
},
{
key: 'security',
forceRender: true,
label: catTabLabel(<SafetyCertificateOutlined />, t('pages.hosts.sections.security'), isMobile),
children: (
<>
<Form.Item name="security" label={t('pages.hosts.fields.security')}>
<Select
options={['same', 'tls', 'none', 'reality'].map((v) => ({ value: v, label: v }))}
/>
</Form.Item>
{showTls && (
<>
<Form.Item name="sni" label={t('pages.hosts.fields.sni')}>
<Input />
</Form.Item>
<Form.Item name="overrideSniFromAddress" label={t('pages.hosts.fields.overrideSniFromAddress')} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="keepSniBlank" label={t('pages.hosts.fields.keepSniBlank')} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="fingerprint" label={t('pages.hosts.fields.fingerprint')}>
<Select allowClear options={fpOptions} />
</Form.Item>
</>
)}
{showTlsExtras && (
<>
<Form.Item name="alpn" label={t('pages.hosts.fields.alpn')}>
<Select mode="multiple" allowClear options={alpnOptions} />
</Form.Item>
<Form.Item name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
<Select mode="tags" allowClear tokenSeparators={[',']} />
</Form.Item>
<Form.Item name="verifyPeerCertByName" label={t('pages.hosts.fields.verifyPeerCertByName')} tooltip={t('pages.inbounds.form.verifyPeerCertByNameTip')}>
<Input placeholder="example.com" />
</Form.Item>
<Form.Item name="allowInsecure" label={t('pages.hosts.fields.allowInsecure')} tooltip={t('pages.hosts.hints.allowInsecure')} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="echConfigList" label={t('pages.hosts.fields.echConfigList')}>
<Input.TextArea rows={2} />
</Form.Item>
</>
)}
</>
),
},
{
key: 'advanced',
forceRender: true,
label: catTabLabel(<ControlOutlined />, t('pages.hosts.sections.advanced'), isMobile),
children: (
<Tabs
size="small"
defaultActiveKey="adv-general"
items={[
{
key: 'adv-general',
forceRender: true,
label: catTabLabel(<SettingOutlined />, t('pages.hosts.sections.general'), isMobile),
children: (
<>
<Form.Item name="hostHeader" label={t('pages.hosts.fields.hostHeader')}>
<Input />
</Form.Item>
<Form.Item name="path" label={t('pages.hosts.fields.path')}>
<Input />
</Form.Item>
<Form.Item name="vlessRoute" label={t('pages.hosts.fields.vlessRoute')} tooltip={t('pages.hosts.hints.vlessRoute')}>
<Input placeholder="443" />
</Form.Item>
<Form.Item name="excludeFromSubTypes" label={t('pages.hosts.fields.excludeFromSubTypes')}>
<Select
mode="multiple"
allowClear
options={['raw', 'json', 'clash'].map((v) => ({ value: v, label: v }))}
<FormProvider {...methods}>
<Form
colon={false}
labelCol={{ sm: { span: 8 } }}
wrapperCol={{ sm: { span: 14 } }}
labelWrap
>
<Tabs
defaultActiveKey="basic"
items={[
{
key: 'basic',
forceRender: true,
label: catTabLabel(<ProfileOutlined />, t('pages.hosts.sections.basic'), isMobile),
children: (
<>
<FormField name="remark" label={t('pages.hosts.fields.remark')} tooltip={t('pages.hosts.hints.remark')} rules={{ validate: rhfZodValidate(HostFormSchema.shape.remark) }}>
<Input maxLength={256} />
</FormField>
<FormField name="serverDescription" label={t('pages.hosts.fields.serverDescription')} tooltip={t('pages.hosts.hints.serverDescription')}>
<Input maxLength={64} />
</FormField>
<FormField name="inboundId" label={t('pages.hosts.fields.inbound')} rules={{ validate: rhfZodValidate(HostFormSchema.shape.inboundId) }}>
<Select
options={inboundSelectOptions}
showSearch
optionFilterProp="label"
disabled={mode === 'edit'}
placeholder={t('pages.hosts.selectInbound')}
/>
</FormField>
<FormField name="address" label={t('pages.hosts.fields.address')} tooltip={t('pages.hosts.hints.address')}>
<Input placeholder="cdn.example.com" />
</FormField>
<FormField name="port" label={t('pages.hosts.fields.port')} tooltip={t('pages.hosts.hints.port')}>
<InputNumber min={0} max={65535} />
</FormField>
<FormField name="tags" label={t('pages.hosts.fields.tags')} tooltip={t('pages.hosts.hints.tags')}>
<Select mode="tags" allowClear tokenSeparators={[',']} />
</FormField>
<FormField name="nodeGuids" label={t('pages.hosts.fields.nodeGuids')} tooltip={t('pages.hosts.hints.nodeGuids')}>
<Select mode="multiple" allowClear options={nodeSelectOptions} optionFilterProp="label" />
</FormField>
<FormField name="enable" label={t('pages.hosts.fields.enable')} valueProp="checked">
<Switch />
</FormField>
</>
),
},
{
key: 'security',
forceRender: true,
label: catTabLabel(<SafetyCertificateOutlined />, t('pages.hosts.sections.security'), isMobile),
children: (
<>
<FormField name="security" label={t('pages.hosts.fields.security')}>
<Select
options={['same', 'tls', 'none', 'reality'].map((v) => ({ value: v, label: v }))}
/>
</FormField>
{showTls && (
<>
<FormField name="sni" label={t('pages.hosts.fields.sni')}>
<Input />
</FormField>
<FormField name="overrideSniFromAddress" label={t('pages.hosts.fields.overrideSniFromAddress')} valueProp="checked">
<Switch />
</FormField>
<FormField name="keepSniBlank" label={t('pages.hosts.fields.keepSniBlank')} valueProp="checked">
<Switch />
</FormField>
<FormField name="fingerprint" label={t('pages.hosts.fields.fingerprint')}>
<Select allowClear options={fpOptions} />
</FormField>
</>
)}
{showTlsExtras && (
<>
<FormField name="alpn" label={t('pages.hosts.fields.alpn')}>
<Select mode="multiple" allowClear options={alpnOptions} />
</FormField>
<FormField name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
<Select mode="tags" allowClear tokenSeparators={[',']} />
</FormField>
<FormField name="verifyPeerCertByName" label={t('pages.hosts.fields.verifyPeerCertByName')} tooltip={t('pages.inbounds.form.verifyPeerCertByNameTip')}>
<Input placeholder="example.com" />
</FormField>
<FormField name="allowInsecure" label={t('pages.hosts.fields.allowInsecure')} tooltip={t('pages.hosts.hints.allowInsecure')} valueProp="checked">
<Switch />
</FormField>
<FormField name="echConfigList" label={t('pages.hosts.fields.echConfigList')}>
<Input.TextArea rows={2} />
</FormField>
</>
)}
</>
),
},
{
key: 'advanced',
forceRender: true,
label: catTabLabel(<ControlOutlined />, t('pages.hosts.sections.advanced'), isMobile),
children: (
<Tabs
size="small"
defaultActiveKey="adv-general"
items={[
{
key: 'adv-general',
forceRender: true,
label: catTabLabel(<SettingOutlined />, t('pages.hosts.sections.general'), isMobile),
children: (
<>
<FormField name="hostHeader" label={t('pages.hosts.fields.hostHeader')}>
<Input />
</FormField>
<FormField name="path" label={t('pages.hosts.fields.path')}>
<Input />
</FormField>
<FormField name="vlessRoute" label={t('pages.hosts.fields.vlessRoute')} tooltip={t('pages.hosts.hints.vlessRoute')}>
<Input placeholder="443" />
</FormField>
<FormField name="excludeFromSubTypes" label={t('pages.hosts.fields.excludeFromSubTypes')}>
<Select
mode="multiple"
allowClear
options={['raw', 'json', 'clash'].map((v) => ({ value: v, label: v }))}
/>
</FormField>
</>
),
},
{
key: 'adv-mux',
forceRender: true,
label: catTabLabel(<PartitionOutlined />, t('pages.hosts.fields.muxParams'), isMobile),
children: (
<Form.Item noStyle>
<Controller
control={methods.control}
name="muxParams"
render={({ field }) => (
<HostMuxForm value={field.value} onChange={field.onChange} />
)}
/>
</Form.Item>
</>
),
},
{
key: 'adv-mux',
forceRender: true,
label: catTabLabel(<PartitionOutlined />, t('pages.hosts.fields.muxParams'), isMobile),
children: (
<Form.Item name="muxParams" noStyle>
<HostMuxForm />
</Form.Item>
),
},
{
key: 'adv-sockopt',
forceRender: true,
label: catTabLabel(<DeploymentUnitOutlined />, t('pages.hosts.fields.sockoptParams'), isMobile),
children: (
<Form.Item name="sockoptParams" noStyle>
<HostSockoptForm />
</Form.Item>
),
},
{
key: 'adv-finalmask',
forceRender: true,
label: catTabLabel(<RocketOutlined />, t('pages.hosts.fields.finalMask'), isMobile),
children: (
<Form.Item name="finalMask" noStyle>
<HostFinalMaskForm />
</Form.Item>
),
},
]}
/>
),
},
{
key: 'clash',
forceRender: true,
label: catTabLabel(<NodeIndexOutlined />, t('pages.hosts.sections.clash'), isMobile),
children: (
<>
<Form.Item name="mihomoIpVersion" label={t('pages.hosts.fields.mihomoIpVersion')}>
<Select
allowClear
options={['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer'].map((v) => ({ value: v, label: v }))}
/>
</Form.Item>
<Form.Item name="mihomoX25519" label={t('pages.hosts.fields.mihomoX25519')} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="shuffleHost" label={t('pages.hosts.fields.shuffleHost')} valuePropName="checked">
<Switch />
</Form.Item>
</>
),
},
]}
/>
</Form>
),
},
{
key: 'adv-sockopt',
forceRender: true,
label: catTabLabel(<DeploymentUnitOutlined />, t('pages.hosts.fields.sockoptParams'), isMobile),
children: (
<Form.Item noStyle>
<Controller
control={methods.control}
name="sockoptParams"
render={({ field }) => (
<HostSockoptForm value={field.value} onChange={field.onChange} />
)}
/>
</Form.Item>
),
},
{
key: 'adv-finalmask',
forceRender: true,
label: catTabLabel(<RocketOutlined />, t('pages.hosts.fields.finalMask'), isMobile),
children: (
<Form.Item noStyle>
<Controller
control={methods.control}
name="finalMask"
render={({ field }) => (
<HostFinalMaskForm value={field.value} onChange={field.onChange} />
)}
/>
</Form.Item>
),
},
]}
/>
),
},
{
key: 'clash',
forceRender: true,
label: catTabLabel(<NodeIndexOutlined />, t('pages.hosts.sections.clash'), isMobile),
children: (
<>
<FormField name="mihomoIpVersion" label={t('pages.hosts.fields.mihomoIpVersion')}>
<Select
allowClear
options={['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer'].map((v) => ({ value: v, label: v }))}
/>
</FormField>
<FormField name="mihomoX25519" label={t('pages.hosts.fields.mihomoX25519')} valueProp="checked">
<Switch />
</FormField>
<FormField name="shuffleHost" label={t('pages.hosts.fields.shuffleHost')} valueProp="checked">
<Switch />
</FormField>
</>
),
},
]}
/>
</Form>
</FormProvider>
</Modal>
);
}
@@ -3,10 +3,12 @@ import { MuxForm } from '@/pages/xray/outbounds/transport';
import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
import { serializeOverride } from './helpers';
// Mux override editor — reuses the outbound MuxForm (same fields as the sub-JSON
// settings editor). Stored in the host's muxParams JSON string. Defaults match
// the sub-JSON editor; the host stores '' (= inherit the inbound/global mux)
// when the toggle is off, an explicit mux object when on.
/*
* Mux override editor — reuses the outbound MuxForm (same fields as the sub-JSON
* settings editor). Stored in the host's muxParams JSON string. Defaults match
* the sub-JSON editor; the host stores '' (= inherit the inbound/global mux)
* when the toggle is off, an explicit mux object when on.
*/
const DEFAULT_MUX = { enabled: false, concurrency: 8, xudpConcurrency: 16, xudpProxyUDP443: 'reject' };
export default function HostMuxForm({ value, onChange }: { value?: string; onChange?: (next: string) => void }) {
@@ -17,9 +19,9 @@ export default function HostMuxForm({ value, onChange }: { value?: string; onCha
path={['mux']}
defaultSubtree={DEFAULT_MUX}
serialize={(mux) => ((mux as { enabled?: boolean } | undefined)?.enabled ? serializeOverride(mux) : '')}
// protocol/network are fixed only to satisfy MuxForm's isMuxAllowed gate;
// a host's mux override is protocol-agnostic and should always be editable.
render={(form) => <MuxForm form={form} protocol="vmess" network="tcp" />}
/* protocol/network are fixed only to satisfy MuxForm's isMuxAllowed gate;
a host's mux override is protocol-agnostic and should always be editable. */
render={() => <MuxForm protocol="vmess" network="tcp" />}
/>
);
}
@@ -4,16 +4,18 @@ import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
import { serializeOverride } from './helpers';
// Sockopt override editor — reuses the outbound SockoptForm (which carries its
// own enable Switch and writes streamSettings.sockopt). Serialized to the host's
// sockoptParams JSON string.
//
// A host is the client/dialer side, so the inbound-only sockopt keys are dropped
// from the output. Verified against xray-core transport/internet/sockopt_*.go:
// only V6Only and the handler-level acceptProxyProtocol / trustedXForwardedFor
// are inbound-only — tproxy (IP_TRANSPARENT) and keepalive/interface ARE applied
// on the outbound/dialer socket, so they stay. The outbound form no longer shows
// the inbound-only keys, but its default object still seeds them, so strip here.
/*
* Sockopt override editor — reuses the outbound SockoptForm (which carries its
* own enable Switch and writes streamSettings.sockopt). Serialized to the host's
* sockoptParams JSON string.
*
* A host is the client/dialer side, so the inbound-only sockopt keys are dropped
* from the output. Verified against xray-core transport/internet/sockopt_*.go:
* only V6Only and the handler-level acceptProxyProtocol / trustedXForwardedFor
* are inbound-only — tproxy (IP_TRANSPARENT) and keepalive/interface ARE applied
* on the outbound/dialer socket, so they stay. The outbound form no longer shows
* the inbound-only keys, but its default object still seeds them, so strip here.
*/
const INBOUND_ONLY_SOCKOPT = ['acceptProxyProtocol', 'V6Only', 'trustedXForwardedFor'];
function serializeClientSockopt(sockopt: unknown): string {
@@ -24,11 +26,13 @@ function serializeClientSockopt(sockopt: unknown): string {
}
export default function HostSockoptForm({ value, onChange }: { value?: string; onChange?: (next: string) => void }) {
// Populate the dialerProxy dropdown with the panel's outbound tags (a host can
// chain through one of the subscription's outbounds by tag). dialerProxy chains
// through a single outbound, so balancers (routing targets) are excluded — only
// the outbound group is used; blackhole is dropped too (chaining to it just
// drops the traffic).
/*
* Populate the dialerProxy dropdown with the panel's outbound tags (a host can
* chain through one of the subscription's outbounds by tag). dialerProxy chains
* through a single outbound, so balancers (routing targets) are excluded — only
* the outbound group is used; blackhole is dropped too (chaining to it just
* drops the traffic).
*/
const { data: tagGroups } = useOutboundTagGroups({ excludeBlackhole: true });
const outboundTags = tagGroups?.outbounds ?? [];
return (
@@ -37,7 +41,7 @@ export default function HostSockoptForm({ value, onChange }: { value?: string; o
onChange={onChange}
path={['streamSettings', 'sockopt']}
serialize={serializeClientSockopt}
render={(form) => <SockoptForm form={form} outboundTags={outboundTags} />}
render={() => <SockoptForm outboundTags={outboundTags} />}
/>
);
}
@@ -1,29 +1,32 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { Form, type FormInstance } from 'antd';
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
import { Form } from 'antd';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import type { FieldValues } from 'react-hook-form';
import { nestAtPath, parseJsonObject, serializeOverride } from './helpers';
interface OutboundSubtreeJsonFormProps {
value?: string;
onChange?: (next: string) => void;
// Form path the inner form edits, e.g. ['streamSettings', 'sockopt'] or ['mux'].
/* Form path the inner form edits, e.g. ['streamSettings', 'sockopt'] or ['mux']. */
path: (string | number)[];
// Renders the reused outbound form given this wrapper's own form instance.
render: (form: FormInstance<OutboundFormValues>) => ReactNode;
// Seeds the form when the stored value is empty, so toggling a section on
// pre-fills sensible defaults instead of blanks (used by Mux).
/* Renders the reused outbound form, which binds to this wrapper's RHF context. */
render: () => ReactNode;
/* Seeds the form when the stored value is empty, so toggling a section on
pre-fills sensible defaults instead of blanks (used by Mux). */
defaultSubtree?: Record<string, unknown>;
// Turns the edited subtree into the stored JSON string (default: prune empties).
// Mux overrides this to store '' (= inherit) when its enable flag is off.
/* Turns the edited subtree into the stored JSON string (default: prune empties).
Mux overrides this to store '' (= inherit) when its enable flag is off. */
serialize?: (subtree: unknown) => string;
}
// Hosts the reused outbound transport forms (which bind to fixed form paths)
// inside an isolated antd Form, mirroring SubJsonFinalMaskForm: seed the form
// from the JSON string, watch the edited subtree, and report a JSON string back
// to the parent host form. component={false} avoids a nested <form> DOM node.
/*
* Hosts the reused outbound transport forms (which bind to fixed RHF paths)
* inside an isolated RHF form, mirroring the sub-JSON adapters: seed the form
* from the JSON string, watch the edited subtree, and report a JSON string back
* to the parent host form. The antd Form is layout-only (component={false}
* avoids a nested <form> DOM node); data binding runs through the RHF provider.
*/
export default function OutboundSubtreeJsonForm({
value = '',
onChange,
@@ -32,37 +35,38 @@ export default function OutboundSubtreeJsonForm({
defaultSubtree,
serialize = serializeOverride,
}: OutboundSubtreeJsonFormProps) {
const [form] = Form.useForm();
const [initial] = useState<Record<string, unknown>>(() => {
const parsed = parseJsonObject(value);
return Object.keys(parsed).length ? parsed : (defaultSubtree ?? {});
});
const [defaultValues] = useState<FieldValues>(() => {
const hasInitial = Object.keys(initial).length > 0;
return nestAtPath(path, hasInitial ? initial : undefined) as FieldValues;
});
const methods = useForm<FieldValues>({ defaultValues });
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const subtree = Form.useWatch(path, form);
const subtree = useWatch({ control: methods.control, name: path.join('.') });
useEffect(() => {
const next = serialize(subtree);
if (next !== value) onChangeRef.current?.(next);
// serialize is logically stable; re-run only when the edited subtree changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
/* serialize is logically stable; re-run only when the edited subtree changes. */
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [subtree, value]);
const hasInitial = Object.keys(initial).length > 0;
const initialValues = nestAtPath(path, hasInitial ? initial : undefined);
return (
<Form
form={form}
component={false}
colon={false}
labelCol={{ sm: { span: 8 } }}
wrapperCol={{ sm: { span: 14 } }}
labelWrap
initialValues={initialValues}
>
{render(form as unknown as FormInstance<OutboundFormValues>)}
</Form>
<FormProvider {...methods}>
<Form
component={false}
colon={false}
labelCol={{ sm: { span: 8 } }}
wrapperCol={{ sm: { span: 14 } }}
labelWrap
>
{render()}
</Form>
</FormProvider>
);
}