mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-24 11:57:15 +00:00
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:
@@ -1,16 +1,18 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd';
|
||||
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import type { Path } from 'react-hook-form';
|
||||
|
||||
import { InputAddon } from '@/components/ui';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import {
|
||||
BalancerFormSchema,
|
||||
type BalancerFormValues,
|
||||
} from '@/schemas/xray';
|
||||
import {
|
||||
BalancerStrategyTypeSchema,
|
||||
type BalancerStrategySettings,
|
||||
type BalancerStrategyType,
|
||||
} from '@/schemas/routing';
|
||||
|
||||
@@ -37,15 +39,7 @@ const STRATEGIES = BalancerStrategyTypeSchema.options.map((value) => ({
|
||||
label: STRATEGY_LABELS[value] ?? value,
|
||||
}));
|
||||
|
||||
interface FormState {
|
||||
tag: string;
|
||||
strategy: BalancerStrategyType;
|
||||
selector: string[];
|
||||
fallbackTag: string;
|
||||
settings?: BalancerStrategySettings;
|
||||
}
|
||||
|
||||
function initialState(balancer: BalancerFormValue | null): FormState {
|
||||
function initialState(balancer: BalancerFormValue | null): BalancerFormValues {
|
||||
if (!balancer) {
|
||||
return { tag: '', strategy: 'random', selector: [], fallbackTag: '' };
|
||||
}
|
||||
@@ -67,64 +61,46 @@ export default function BalancerFormModal({
|
||||
onConfirm,
|
||||
}: BalancerFormModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<FormState>(() => initialState(balancer));
|
||||
const [touched, setTouched] = useState<Partial<Record<keyof FormState, boolean>>>({});
|
||||
const methods = useForm<BalancerFormValues>({ defaultValues: initialState(balancer) });
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
const isEdit = balancer != null;
|
||||
|
||||
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
|
||||
setTouched((prev) => (prev[key] ? prev : { ...prev, [key]: true }));
|
||||
setState((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const parsed = useMemo(
|
||||
() => BalancerFormSchema.safeParse(state),
|
||||
[state],
|
||||
);
|
||||
const duplicateTag = !!state.tag.trim() && otherTags.includes(state.tag.trim());
|
||||
const issues = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
if (!parsed.success) {
|
||||
for (const issue of parsed.error.issues) {
|
||||
const key = String(issue.path[0] ?? '');
|
||||
if (!map[key]) map[key] = t(issue.message, { defaultValue: issue.message });
|
||||
}
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
methods.reset(initialState(balancer));
|
||||
setSubmitAttempted(false);
|
||||
}
|
||||
return map;
|
||||
}, [parsed, t]);
|
||||
}, [open, balancer, methods]);
|
||||
|
||||
const showTagIssue = submitAttempted || !!touched.tag;
|
||||
const showSelectorIssue = submitAttempted || !!touched.selector;
|
||||
const tagError = showTagIssue ? issues.tag : '';
|
||||
const selectorError = showSelectorIssue ? issues.selector : '';
|
||||
const showDuplicate = showTagIssue && duplicateTag;
|
||||
const strategy = useWatch({ control: methods.control, name: 'strategy' });
|
||||
const baselines = useWatch({ control: methods.control, name: 'settings.baselines' }) ?? [];
|
||||
const costs = useWatch({ control: methods.control, name: 'settings.costs' }) ?? [];
|
||||
|
||||
function submit() {
|
||||
const values = methods.getValues();
|
||||
const parsed = BalancerFormSchema.safeParse(values);
|
||||
const trimmedTag = (values.tag ?? '').trim();
|
||||
const duplicateTag = !!trimmedTag && otherTags.includes(trimmedTag);
|
||||
methods.clearErrors();
|
||||
if (!parsed.success) {
|
||||
const seen = new Set<string>();
|
||||
for (const issue of parsed.error.issues) {
|
||||
const key = String(issue.path[0] ?? '');
|
||||
if (key && !seen.has(key)) {
|
||||
seen.add(key);
|
||||
methods.setError(key as Path<BalancerFormValues>, { message: issue.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!parsed.success || duplicateTag) {
|
||||
setSubmitAttempted(true);
|
||||
return;
|
||||
}
|
||||
const values = { ...parsed.data };
|
||||
if (values.strategy !== 'leastLoad') delete values.settings;
|
||||
onConfirm(values);
|
||||
const result: BalancerFormValues = { ...parsed.data };
|
||||
if (result.strategy !== 'leastLoad') delete result.settings;
|
||||
onConfirm(result);
|
||||
}
|
||||
|
||||
const settings = state.settings;
|
||||
const updateSetting = <K extends keyof BalancerStrategySettings>(
|
||||
key: K,
|
||||
value: BalancerStrategySettings[K],
|
||||
) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
settings: { ...(prev.settings ?? {}), [key]: value },
|
||||
}));
|
||||
};
|
||||
const updateBaselines = (next: string[]) => updateSetting('baselines', next);
|
||||
const updateCosts = (next: NonNullable<BalancerStrategySettings['costs']>) => updateSetting('costs', next);
|
||||
|
||||
const baselines = settings?.baselines ?? [];
|
||||
const costs = settings?.costs ?? [];
|
||||
|
||||
const fallbackOptions = useMemo(
|
||||
() => ['', ...outboundTags].map((tg) => ({ value: tg, label: tg || `(${t('none')})` })),
|
||||
[outboundTags, t],
|
||||
@@ -145,141 +121,144 @@ export default function BalancerFormModal({
|
||||
onOk={submit}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
|
||||
<Form.Item
|
||||
label={t('pages.xray.balancer.tag')}
|
||||
required
|
||||
validateStatus={tagError ? 'error' : showDuplicate ? 'warning' : ''}
|
||||
help={tagError || (showDuplicate ? t('pages.xray.balancer.tagDuplicate') : '')}
|
||||
hasFeedback
|
||||
>
|
||||
<Input
|
||||
value={state.tag}
|
||||
onChange={(e) => update('tag', e.target.value)}
|
||||
placeholder={t('pages.xray.balancer.tagPlaceholder')}
|
||||
<FormProvider {...methods}>
|
||||
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name="tag"
|
||||
render={({ field, fieldState }) => {
|
||||
const trimmed = (field.value ?? '').trim();
|
||||
const duplicate = !!trimmed && otherTags.includes(trimmed);
|
||||
const errorMessage = fieldState.error?.message
|
||||
? t(fieldState.error.message, { defaultValue: fieldState.error.message })
|
||||
: '';
|
||||
const showDuplicate = !errorMessage && (submitAttempted || fieldState.isTouched) && duplicate;
|
||||
return (
|
||||
<Form.Item
|
||||
label={t('pages.xray.balancer.tag')}
|
||||
required
|
||||
validateStatus={errorMessage ? 'error' : showDuplicate ? 'warning' : ''}
|
||||
help={errorMessage || (showDuplicate ? t('pages.xray.balancer.tagDuplicate') : '')}
|
||||
hasFeedback
|
||||
>
|
||||
<Input
|
||||
value={field.value}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
placeholder={t('pages.xray.balancer.tagPlaceholder')}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.balancer.balancerStrategy')}>
|
||||
<Select
|
||||
value={state.strategy}
|
||||
onChange={(v) => update('strategy', v)}
|
||||
options={STRATEGIES}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.xray.balancer.selector')}
|
||||
required
|
||||
validateStatus={selectorError ? 'error' : ''}
|
||||
help={selectorError || ''}
|
||||
hasFeedback
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
value={state.selector}
|
||||
onChange={(v) => update('selector', v)}
|
||||
tokenSeparators={[',']}
|
||||
options={outboundTags.map((tg) => ({ value: tg, label: tg }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.balancer.fallback')}>
|
||||
<Select
|
||||
value={state.fallbackTag}
|
||||
onChange={(v) => update('fallbackTag', v ?? '')}
|
||||
allowClear
|
||||
options={fallbackOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
<FormField name="strategy" label={t('pages.xray.balancer.balancerStrategy')}>
|
||||
<Select options={STRATEGIES} />
|
||||
</FormField>
|
||||
<FormField name="selector" label={t('pages.xray.balancer.selector')} required>
|
||||
<Select
|
||||
mode="tags"
|
||||
tokenSeparators={[',']}
|
||||
options={outboundTags.map((tg) => ({ value: tg, label: tg }))}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
name="fallbackTag"
|
||||
label={t('pages.xray.balancer.fallback')}
|
||||
transform={{ output: (v) => v ?? '' }}
|
||||
>
|
||||
<Select allowClear options={fallbackOptions} />
|
||||
</FormField>
|
||||
|
||||
{state.strategy === 'leastLoad' && (
|
||||
<>
|
||||
<Form.Item label={t('pages.xray.balancer.expected')}>
|
||||
<InputNumber
|
||||
value={settings?.expected}
|
||||
onChange={(v) => updateSetting('expected', typeof v === 'number' ? v : undefined)}
|
||||
min={0}
|
||||
placeholder={t('pages.xray.balancer.expectedPlaceholder')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.balancer.maxRtt')}>
|
||||
<Input
|
||||
value={settings?.maxRTT ?? ''}
|
||||
onChange={(e) => updateSetting('maxRTT', e.target.value || undefined)}
|
||||
placeholder="e.g. 1s"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.balancer.tolerance')}>
|
||||
<InputNumber
|
||||
value={settings?.tolerance}
|
||||
onChange={(v) => updateSetting('tolerance', typeof v === 'number' ? v : undefined)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
placeholder="0.01 = 1%"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.balancer.baselines')}>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() => updateBaselines([...baselines, ''])}
|
||||
/>
|
||||
{baselines.map((b, idx) => (
|
||||
<Space.Compact key={idx} block style={{ marginTop: 4 }}>
|
||||
<Input
|
||||
value={b}
|
||||
aria-label={t('pages.xray.balancer.baselines')}
|
||||
placeholder="e.g. 1s"
|
||||
onChange={(e) => updateBaselines(baselines.map((x, i) => (i === idx ? e.target.value : x)))}
|
||||
/>
|
||||
<InputAddon ariaLabel={t('remove')} onClick={() => updateBaselines(baselines.filter((_, i) => i !== idx))}>
|
||||
<MinusOutlined />
|
||||
</InputAddon>
|
||||
</Space.Compact>
|
||||
))}
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.balancer.costs')}>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() => updateCosts([...costs, { regexp: false, match: '', value: 1 }])}
|
||||
/>
|
||||
{costs.map((c, idx) => (
|
||||
<Space.Compact key={idx} block style={{ marginTop: 4 }}>
|
||||
<Switch
|
||||
checked={c.regexp}
|
||||
aria-label={t('pages.xray.balancer.costRegexp')}
|
||||
checkedChildren="re"
|
||||
unCheckedChildren="lit"
|
||||
onChange={(v) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, regexp: v } : x)))}
|
||||
/>
|
||||
<Input
|
||||
value={c.match}
|
||||
aria-label={t('pages.xray.balancer.costMatch')}
|
||||
placeholder="tag pattern"
|
||||
onChange={(e) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, match: e.target.value } : x)))}
|
||||
/>
|
||||
<InputNumber
|
||||
value={c.value}
|
||||
aria-label={t('pages.xray.balancer.costValue')}
|
||||
placeholder="weight"
|
||||
style={{ width: 100 }}
|
||||
onChange={(v) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, value: typeof v === 'number' ? v : 0 } : x)))}
|
||||
/>
|
||||
<InputAddon ariaLabel={t('remove')} onClick={() => updateCosts(costs.filter((_, i) => i !== idx))}>
|
||||
<MinusOutlined />
|
||||
</InputAddon>
|
||||
</Space.Compact>
|
||||
))}
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
{strategy === 'leastLoad' && (
|
||||
<>
|
||||
<FormField
|
||||
name={['settings', 'expected']}
|
||||
label={t('pages.xray.balancer.expected')}
|
||||
transform={{ output: (v) => (typeof v === 'number' ? v : undefined) }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
placeholder={t('pages.xray.balancer.expectedPlaceholder')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['settings', 'maxRTT']}
|
||||
label={t('pages.xray.balancer.maxRtt')}
|
||||
transform={{ input: (v) => v ?? '', output: (v) => (typeof v === 'string' && v ? v : undefined) }}
|
||||
>
|
||||
<Input placeholder="e.g. 1s" />
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['settings', 'tolerance']}
|
||||
label={t('pages.xray.balancer.tolerance')}
|
||||
transform={{ output: (v) => (typeof v === 'number' ? v : undefined) }}
|
||||
>
|
||||
<InputNumber min={0} max={1} step={0.01} placeholder="0.01 = 1%" style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
<Form.Item label={t('pages.xray.balancer.baselines')}>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() => methods.setValue('settings.baselines', [...baselines, ''])}
|
||||
/>
|
||||
{baselines.map((b, idx) => (
|
||||
<Space.Compact key={idx} block style={{ marginTop: 4 }}>
|
||||
<Input
|
||||
value={b}
|
||||
aria-label={t('pages.xray.balancer.baselines')}
|
||||
placeholder="e.g. 1s"
|
||||
onChange={(e) => methods.setValue('settings.baselines', baselines.map((x, i) => (i === idx ? e.target.value : x)))}
|
||||
/>
|
||||
<InputAddon ariaLabel={t('remove')} onClick={() => methods.setValue('settings.baselines', baselines.filter((_, i) => i !== idx))}>
|
||||
<MinusOutlined />
|
||||
</InputAddon>
|
||||
</Space.Compact>
|
||||
))}
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.balancer.costs')}>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() => methods.setValue('settings.costs', [...costs, { regexp: false, match: '', value: 1 }])}
|
||||
/>
|
||||
{costs.map((c, idx) => (
|
||||
<Space.Compact key={idx} block style={{ marginTop: 4 }}>
|
||||
<Switch
|
||||
checked={c.regexp}
|
||||
aria-label={t('pages.xray.balancer.costRegexp')}
|
||||
checkedChildren="re"
|
||||
unCheckedChildren="lit"
|
||||
onChange={(v) => methods.setValue('settings.costs', costs.map((x, i) => (i === idx ? { ...x, regexp: v } : x)))}
|
||||
/>
|
||||
<Input
|
||||
value={c.match}
|
||||
aria-label={t('pages.xray.balancer.costMatch')}
|
||||
placeholder="tag pattern"
|
||||
onChange={(e) => methods.setValue('settings.costs', costs.map((x, i) => (i === idx ? { ...x, match: e.target.value } : x)))}
|
||||
/>
|
||||
<InputNumber
|
||||
value={c.value}
|
||||
aria-label={t('pages.xray.balancer.costValue')}
|
||||
placeholder="weight"
|
||||
style={{ width: 100 }}
|
||||
onChange={(v) => methods.setValue('settings.costs', costs.map((x, i) => (i === idx ? { ...x, value: typeof v === 'number' ? v : 0 } : x)))}
|
||||
/>
|
||||
<InputAddon ariaLabel={t('remove')} onClick={() => methods.setValue('settings.costs', costs.filter((_, i) => i !== idx))}>
|
||||
<MinusOutlined />
|
||||
</InputAddon>
|
||||
</Space.Compact>
|
||||
))}
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,15 +2,16 @@ import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Divider, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd';
|
||||
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
|
||||
import { InputAddon } from '@/components/ui';
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import {
|
||||
DnsQueryStrategySchema,
|
||||
DnsServerObjectInnerSchema,
|
||||
DnsServerObjectSchema,
|
||||
type DnsServerObject,
|
||||
} from '@/schemas/dns';
|
||||
import { antdRule } from '@/utils/zodForm';
|
||||
|
||||
export type DnsServerValue =
|
||||
| string
|
||||
@@ -135,17 +136,15 @@ export default function DnsServerModal({
|
||||
onConfirm,
|
||||
}: DnsServerModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm<DnsServerForm>();
|
||||
const methods = useForm<DnsServerForm>({ defaultValues: defaultFormValues() });
|
||||
const domains = useWatch({ control: methods.control, name: 'domains' }) ?? [];
|
||||
const expectedIPs = useWatch({ control: methods.control, name: 'expectedIPs' }) ?? [];
|
||||
const unexpectedIPs = useWatch({ control: methods.control, name: 'unexpectedIPs' }) ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
form.setFieldsValue(valuesFromServer(server));
|
||||
}, [open, server, form]);
|
||||
|
||||
async function submit() {
|
||||
const values = await form.validateFields();
|
||||
onConfirm(valuesToWire(values));
|
||||
}
|
||||
methods.reset(valuesFromServer(server));
|
||||
}, [open, server, methods]);
|
||||
|
||||
const title = isEdit ? t('pages.xray.dns.edit') : t('pages.xray.dns.add');
|
||||
|
||||
@@ -156,124 +155,112 @@ export default function DnsServerModal({
|
||||
okText={t('confirm')}
|
||||
cancelText={t('close')}
|
||||
mask={{ closable: false }}
|
||||
onOk={submit}
|
||||
onOk={methods.handleSubmit((values) => onConfirm(valuesToWire(values)))}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
colon={false}
|
||||
labelCol={{ md: { span: 8 } }}
|
||||
wrapperCol={{ md: { span: 14 } }}
|
||||
initialValues={defaultFormValues()}
|
||||
>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.address')}
|
||||
name="address"
|
||||
rules={[antdRule(shape.address, t)]}
|
||||
<FormProvider {...methods}>
|
||||
<Form
|
||||
colon={false}
|
||||
labelCol={{ md: { span: 8 } }}
|
||||
wrapperCol={{ md: { span: 14 } }}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.port')}
|
||||
name="port"
|
||||
rules={[antdRule(shape.port, t)]}
|
||||
>
|
||||
<InputNumber min={1} max={65535} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.dns.tag')} name="tag">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.dns.clientIp')} name="clientIP">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.dns.strategy')} name="queryStrategy">
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
options={STRATEGIES.map((s) => ({ value: s, label: s }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.xray.dns.timeoutMs')}
|
||||
name="timeoutMs"
|
||||
rules={[antdRule(shape.timeoutMs, t)]}
|
||||
>
|
||||
<InputNumber min={0} step={500} />
|
||||
</Form.Item>
|
||||
<FormField
|
||||
label={t('pages.inbounds.address')}
|
||||
name="address"
|
||||
rules={{ validate: rhfZodValidate(shape.address) }}
|
||||
>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.port')}
|
||||
name="port"
|
||||
rules={{ validate: rhfZodValidate(shape.port) }}
|
||||
>
|
||||
<InputNumber min={1} max={65535} />
|
||||
</FormField>
|
||||
<FormField label={t('pages.xray.dns.tag')} name="tag">
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField label={t('pages.xray.dns.clientIp')} name="clientIP">
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField label={t('pages.xray.dns.strategy')} name="queryStrategy">
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
options={STRATEGIES.map((s) => ({ value: s, label: s }))}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.xray.dns.timeoutMs')}
|
||||
name="timeoutMs"
|
||||
rules={{ validate: rhfZodValidate(shape.timeoutMs) }}
|
||||
>
|
||||
<InputNumber min={0} step={500} />
|
||||
</FormField>
|
||||
|
||||
<Divider style={{ margin: '5px 0' }} />
|
||||
<Divider style={{ margin: '5px 0' }} />
|
||||
|
||||
<Form.List name="domains">
|
||||
{(fields, { add, remove }) => (
|
||||
<Form.Item label={t('pages.xray.dns.domains')}>
|
||||
<Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => add('')} />
|
||||
{fields.map((field) => (
|
||||
<Space.Compact key={field.key} block style={{ marginTop: 4 }}>
|
||||
<Form.Item name={field.name} noStyle>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<InputAddon ariaLabel={t('remove')} onClick={() => remove(field.name)}>
|
||||
<MinusOutlined />
|
||||
</InputAddon>
|
||||
</Space.Compact>
|
||||
))}
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form.List>
|
||||
<Form.Item label={t('pages.xray.dns.domains')}>
|
||||
<Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => methods.setValue('domains', [...domains, ''])} />
|
||||
{domains.map((_, i) => (
|
||||
<Space.Compact key={i} block style={{ marginTop: 4 }}>
|
||||
<FormField name={`domains.${i}`} noStyle>
|
||||
<Input />
|
||||
</FormField>
|
||||
<InputAddon ariaLabel={t('remove')} onClick={() => methods.setValue('domains', domains.filter((__, idx) => idx !== i))}>
|
||||
<MinusOutlined />
|
||||
</InputAddon>
|
||||
</Space.Compact>
|
||||
))}
|
||||
</Form.Item>
|
||||
|
||||
<Form.List name="expectedIPs">
|
||||
{(fields, { add, remove }) => (
|
||||
<Form.Item label={t('pages.xray.dns.expectIPs')}>
|
||||
<Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => add('')} />
|
||||
{fields.map((field) => (
|
||||
<Space.Compact key={field.key} block style={{ marginTop: 4 }}>
|
||||
<Form.Item name={field.name} noStyle>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<InputAddon ariaLabel={t('remove')} onClick={() => remove(field.name)}>
|
||||
<MinusOutlined />
|
||||
</InputAddon>
|
||||
</Space.Compact>
|
||||
))}
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form.List>
|
||||
<Form.Item label={t('pages.xray.dns.expectIPs')}>
|
||||
<Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => methods.setValue('expectedIPs', [...expectedIPs, ''])} />
|
||||
{expectedIPs.map((_, i) => (
|
||||
<Space.Compact key={i} block style={{ marginTop: 4 }}>
|
||||
<FormField name={`expectedIPs.${i}`} noStyle>
|
||||
<Input />
|
||||
</FormField>
|
||||
<InputAddon ariaLabel={t('remove')} onClick={() => methods.setValue('expectedIPs', expectedIPs.filter((__, idx) => idx !== i))}>
|
||||
<MinusOutlined />
|
||||
</InputAddon>
|
||||
</Space.Compact>
|
||||
))}
|
||||
</Form.Item>
|
||||
|
||||
<Form.List name="unexpectedIPs">
|
||||
{(fields, { add, remove }) => (
|
||||
<Form.Item label={t('pages.xray.dns.unexpectIPs')}>
|
||||
<Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => add('')} />
|
||||
{fields.map((field) => (
|
||||
<Space.Compact key={field.key} block style={{ marginTop: 4 }}>
|
||||
<Form.Item name={field.name} noStyle>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<InputAddon ariaLabel={t('remove')} onClick={() => remove(field.name)}>
|
||||
<MinusOutlined />
|
||||
</InputAddon>
|
||||
</Space.Compact>
|
||||
))}
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form.List>
|
||||
<Form.Item label={t('pages.xray.dns.unexpectIPs')}>
|
||||
<Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => methods.setValue('unexpectedIPs', [...unexpectedIPs, ''])} />
|
||||
{unexpectedIPs.map((_, i) => (
|
||||
<Space.Compact key={i} block style={{ marginTop: 4 }}>
|
||||
<FormField name={`unexpectedIPs.${i}`} noStyle>
|
||||
<Input />
|
||||
</FormField>
|
||||
<InputAddon ariaLabel={t('remove')} onClick={() => methods.setValue('unexpectedIPs', unexpectedIPs.filter((__, idx) => idx !== i))}>
|
||||
<MinusOutlined />
|
||||
</InputAddon>
|
||||
</Space.Compact>
|
||||
))}
|
||||
</Form.Item>
|
||||
|
||||
<Divider style={{ margin: '5px 0' }} />
|
||||
<Divider style={{ margin: '5px 0' }} />
|
||||
|
||||
<Form.Item label={t('pages.xray.dns.skipFallback')} name="skipFallback" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.dns.finalQuery')} name="finalQuery" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.dns.disableCache')} name="disableCache" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.dns.serveStale')} name="serveStale" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.dns.serveExpiredTTL')} name="serveExpiredTTL">
|
||||
<InputNumber min={0} step={60} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<FormField label={t('pages.xray.dns.skipFallback')} name="skipFallback" valueProp="checked">
|
||||
<Switch />
|
||||
</FormField>
|
||||
<FormField label={t('pages.xray.dns.finalQuery')} name="finalQuery" valueProp="checked">
|
||||
<Switch />
|
||||
</FormField>
|
||||
<FormField label={t('pages.xray.dns.disableCache')} name="disableCache" valueProp="checked">
|
||||
<Switch />
|
||||
</FormField>
|
||||
<FormField label={t('pages.xray.dns.serveStale')} name="serveStale" valueProp="checked">
|
||||
<Switch />
|
||||
</FormField>
|
||||
<FormField label={t('pages.xray.dns.serveExpiredTTL')} name="serveExpiredTTL">
|
||||
<InputNumber min={0} step={60} />
|
||||
</FormField>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@ import {
|
||||
Tabs,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { FinalMaskForm } from '@/lib/xray/forms/transport';
|
||||
import SniffingFields from '@/lib/xray/forms/SniffingFields';
|
||||
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import { FinalMaskField, SniffingField } from '@/lib/xray/forms/fields';
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import { JsonEditor } from '@/components/form';
|
||||
import { Wireguard } from '@/utils';
|
||||
import {
|
||||
@@ -31,7 +32,6 @@ import {
|
||||
canEnableTls,
|
||||
canEnableTlsFlow,
|
||||
} from '@/lib/xray/protocol-capabilities';
|
||||
import { antdRule } from '@/utils/zodForm';
|
||||
|
||||
import {
|
||||
FLOW_OPTIONS,
|
||||
@@ -75,10 +75,7 @@ import {
|
||||
import { RealityForm, TlsForm } from './security';
|
||||
import './OutboundFormModal.css';
|
||||
|
||||
// Pattern A rewrite of OutboundFormModal. Built as a sibling `.new.tsx`
|
||||
// file so the build stays green section-by-section. The atomic swap at
|
||||
// the end of the rewrite replaces the legacy file in one commit
|
||||
// (per Core Decision 7 in the migration spec).
|
||||
type StreamValue = OutboundFormValues['streamSettings'];
|
||||
|
||||
interface OutboundFormModalProps {
|
||||
open: boolean;
|
||||
@@ -89,7 +86,6 @@ interface OutboundFormModalProps {
|
||||
onConfirm: (outbound: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
|
||||
export default function OutboundFormModal({
|
||||
open,
|
||||
outbound: outboundProp,
|
||||
@@ -100,15 +96,36 @@ export default function OutboundFormModal({
|
||||
}: OutboundFormModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [form] = Form.useForm<OutboundFormValues>();
|
||||
const methods = useForm<OutboundFormValues>({ defaultValues: buildAddModeValues() });
|
||||
const [activeKey, setActiveKey] = useState('1');
|
||||
const [jsonText, setJsonText] = useState('');
|
||||
const [jsonDirty, setJsonDirty] = useState(false);
|
||||
const [linkInput, setLinkInput] = useState('');
|
||||
|
||||
// Parse a share link (vmess:// / vless:// / trojan:// / ss:// /
|
||||
// hysteria2:// / wireguard://) and replace form state with the result.
|
||||
// The current tag is preserved when the parsed link doesn't carry one.
|
||||
const isEdit = outboundProp != null;
|
||||
const title = isEdit
|
||||
? `${t('edit')} ${t('pages.xray.Outbounds')}`
|
||||
: `+ ${t('pages.xray.Outbounds')}`;
|
||||
const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
|
||||
|
||||
const tag = (useWatch({ control: methods.control, name: 'tag' }) ?? '') as string;
|
||||
const protocol = (useWatch({ control: methods.control, name: 'protocol' }) ?? 'vless') as string;
|
||||
const network = (useWatch({ control: methods.control, name: 'streamSettings.network' }) ?? '') as string;
|
||||
const security = (useWatch({ control: methods.control, name: 'streamSettings.security' }) ?? 'none') as string;
|
||||
const flow = (useWatch({ control: methods.control, name: 'settings.flow' }) ?? '') as string;
|
||||
const reverseTag = useWatch({ control: methods.control, name: 'settings.reverseTag' });
|
||||
const wgSecretKey = useWatch({ control: methods.control, name: 'settings.secretKey' }) as string | undefined;
|
||||
|
||||
const streamAllowed = canEnableStream({ protocol });
|
||||
const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } });
|
||||
const realityAllowed = canEnableReality({ protocol, streamSettings: { network, security } });
|
||||
const tlsFlowAllowed = canEnableTlsFlow({ protocol, streamSettings: { network, security } });
|
||||
|
||||
/*
|
||||
* Parse a share link (vmess:// / vless:// / trojan:// / ss:// /
|
||||
* hysteria2:// / wireguard://) and replace form state with the result.
|
||||
* The current tag is preserved when the parsed link doesn't carry one.
|
||||
*/
|
||||
function importLink() {
|
||||
const link = linkInput.trim();
|
||||
if (!link) return;
|
||||
@@ -117,11 +134,10 @@ export default function OutboundFormModal({
|
||||
messageApi.error('Wrong Link!');
|
||||
return;
|
||||
}
|
||||
const currentTag = form.getFieldValue('tag') as string | undefined;
|
||||
const currentTag = methods.getValues('tag');
|
||||
if (!parsed.tag && currentTag) parsed.tag = currentTag;
|
||||
const next = rawOutboundToFormValues(parsed);
|
||||
form.resetFields();
|
||||
form.setFieldsValue(next);
|
||||
methods.reset(next);
|
||||
setJsonText(JSON.stringify(formValuesToWirePayload(next), null, 2));
|
||||
setJsonDirty(false);
|
||||
setLinkInput('');
|
||||
@@ -129,89 +145,76 @@ export default function OutboundFormModal({
|
||||
switchTab('1');
|
||||
}
|
||||
|
||||
const isEdit = outboundProp != null;
|
||||
const title = isEdit
|
||||
? `${t('edit')} ${t('pages.xray.Outbounds')}`
|
||||
: `+ ${t('pages.xray.Outbounds')}`;
|
||||
const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const initial = outboundProp
|
||||
? rawOutboundToFormValues(outboundProp)
|
||||
: buildAddModeValues();
|
||||
form.resetFields();
|
||||
form.setFieldsValue(initial);
|
||||
methods.reset(initial);
|
||||
setActiveKey('1');
|
||||
setJsonText(JSON.stringify(formValuesToWirePayload(initial), null, 2));
|
||||
setJsonDirty(false);
|
||||
}, [open, outboundProp, form]);
|
||||
|
||||
const tag = Form.useWatch('tag', form) ?? '';
|
||||
const protocol = (Form.useWatch('protocol', form) ?? 'vless') as string;
|
||||
const network = (Form.useWatch(['streamSettings', 'network'], { form, preserve: true }) ?? '') as string;
|
||||
const security = (Form.useWatch(['streamSettings', 'security'], { form, preserve: true }) ?? 'none') as string;
|
||||
const streamAllowed = canEnableStream({ protocol });
|
||||
const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } });
|
||||
const realityAllowed = canEnableReality({ protocol, streamSettings: { network, security } });
|
||||
const tlsFlowAllowed = canEnableTlsFlow({ protocol, streamSettings: { network, security } });
|
||||
}, [open, outboundProp, methods]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!streamAllowed) return;
|
||||
// Wireguard dials its own UDP — only finalmask/sockopt apply, never a
|
||||
// transport. Don't seed network 'tcp'; clear a leftover one (from a
|
||||
// protocol switch) so the transmission/security blocks stay hidden.
|
||||
/*
|
||||
* Wireguard dials its own UDP — only finalmask/sockopt apply, never a
|
||||
* transport. Don't seed network 'tcp'; clear a leftover one (from a
|
||||
* protocol switch) so the transmission/security blocks stay hidden.
|
||||
*/
|
||||
if (protocol === 'wireguard') {
|
||||
if (network) form.setFieldValue('streamSettings', { security: 'none' });
|
||||
if (network) methods.setValue('streamSettings', { security: 'none' } as StreamValue);
|
||||
return;
|
||||
}
|
||||
if (network) return;
|
||||
form.setFieldValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [streamAllowed, network, protocol]);
|
||||
methods.setValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' } as StreamValue);
|
||||
}, [streamAllowed, network, protocol, methods]);
|
||||
|
||||
useEffect(() => {
|
||||
if (protocol !== 'hysteria') return;
|
||||
if (network === 'hysteria' && security === 'tls') return;
|
||||
const existing = (form.getFieldValue('streamSettings') ?? {}) as Record<string, unknown>;
|
||||
const existing = (methods.getValues('streamSettings') ?? {}) as Record<string, unknown>;
|
||||
const slice = hysteriaStreamSlice();
|
||||
if (existing.hysteriaSettings) slice.hysteriaSettings = existing.hysteriaSettings;
|
||||
if (existing.tlsSettings) slice.tlsSettings = existing.tlsSettings;
|
||||
form.setFieldValue('streamSettings', slice);
|
||||
}, [protocol, network, security, form]);
|
||||
methods.setValue('streamSettings', slice as StreamValue);
|
||||
}, [protocol, network, security, methods]);
|
||||
|
||||
const wgSecretKey = Form.useWatch(['settings', 'secretKey'], form) as string | undefined;
|
||||
useEffect(() => {
|
||||
if (protocol !== 'wireguard') return;
|
||||
const sk = (wgSecretKey ?? '').trim();
|
||||
if (!sk) {
|
||||
form.setFieldValue(['settings', 'pubKey'], '');
|
||||
methods.setValue('settings.pubKey', '');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { publicKey } = Wireguard.generateKeypair(sk);
|
||||
form.setFieldValue(['settings', 'pubKey'], publicKey);
|
||||
methods.setValue('settings.pubKey', publicKey);
|
||||
} catch {
|
||||
form.setFieldValue(['settings', 'pubKey'], '');
|
||||
methods.setValue('settings.pubKey', '');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [protocol, wgSecretKey]);
|
||||
}, [protocol, wgSecretKey, methods]);
|
||||
|
||||
function onValuesChange(changed: Partial<OutboundFormValues>) {
|
||||
if ('protocol' in changed && changed.protocol) {
|
||||
const next = rawOutboundToFormValues({ protocol: changed.protocol });
|
||||
form.setFieldValue('settings', next.settings);
|
||||
if (changed.protocol === 'hysteria') {
|
||||
form.setFieldValue('streamSettings', hysteriaStreamSlice());
|
||||
} else if ((form.getFieldValue(['streamSettings', 'network']) ?? '') === 'hysteria') {
|
||||
form.setFieldValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' });
|
||||
useEffect(() => {
|
||||
/* eslint-disable-next-line react-hooks/incompatible-library */
|
||||
const sub = methods.watch((_value, { name, type }) => {
|
||||
if (name !== 'protocol' || type !== 'change') return;
|
||||
const nextProtocol = methods.getValues('protocol');
|
||||
const next = rawOutboundToFormValues({ protocol: nextProtocol });
|
||||
methods.setValue('settings', next.settings);
|
||||
if (nextProtocol === 'hysteria') {
|
||||
methods.setValue('streamSettings', hysteriaStreamSlice() as StreamValue);
|
||||
} else if ((methods.getValues('streamSettings.network') ?? '') === 'hysteria') {
|
||||
methods.setValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' } as StreamValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
}, [methods]);
|
||||
|
||||
function onSecurityChange(next: string) {
|
||||
const stream = form.getFieldValue('streamSettings') ?? {};
|
||||
const cleaned = { ...stream } as Record<string, unknown>;
|
||||
const stream = (methods.getValues('streamSettings') ?? {}) as Record<string, unknown>;
|
||||
const cleaned = { ...stream };
|
||||
delete cleaned.tlsSettings;
|
||||
delete cleaned.realitySettings;
|
||||
if (next === 'tls') {
|
||||
@@ -234,23 +237,25 @@ export default function OutboundFormModal({
|
||||
};
|
||||
}
|
||||
cleaned.security = next;
|
||||
form.setFieldValue('streamSettings', cleaned);
|
||||
methods.setValue('streamSettings', cleaned as StreamValue);
|
||||
}
|
||||
|
||||
// Network change cascade: swap the per-network sub-key (tcpSettings,
|
||||
// wsSettings, etc.) so the DU branch matches. Preserve security if
|
||||
// the new network supports it, otherwise force back to 'none'.
|
||||
/*
|
||||
* Network change cascade: swap the per-network sub-key (tcpSettings,
|
||||
* wsSettings, etc.) so the DU branch matches. Preserve security if
|
||||
* the new network supports it, otherwise force back to 'none'.
|
||||
*/
|
||||
function onNetworkChange(next: string) {
|
||||
const stream = (form.getFieldValue('streamSettings') ?? {}) as Record<string, unknown>;
|
||||
form.setFieldValue('streamSettings', applyNetworkChange(protocol, stream, next));
|
||||
const stream = (methods.getValues('streamSettings') ?? {}) as Record<string, unknown>;
|
||||
methods.setValue('streamSettings', applyNetworkChange(protocol, stream, next) as StreamValue);
|
||||
}
|
||||
|
||||
function onXmuxToggle(checked: boolean) {
|
||||
if (!checked) return;
|
||||
const existing = form.getFieldValue(['streamSettings', 'xhttpSettings', 'xmux']);
|
||||
const existing = methods.getValues('streamSettings.xhttpSettings.xmux');
|
||||
const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
|
||||
if (hasValues) return;
|
||||
form.setFieldValue(['streamSettings', 'xhttpSettings', 'xmux'], { ...XMUX_DEFAULTS });
|
||||
methods.setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_DEFAULTS });
|
||||
}
|
||||
|
||||
const duplicateTag = useMemo(() => {
|
||||
@@ -260,9 +265,11 @@ export default function OutboundFormModal({
|
||||
return (existingTags || []).includes(myTag);
|
||||
}, [tag, existingTags, isEdit, outboundProp]);
|
||||
|
||||
// Bridge form ↔ JSON tab: when leaving the JSON tab back to Basic, push
|
||||
// any edits into form state. When entering JSON tab, snapshot current
|
||||
// form values so the user sees the live shape.
|
||||
/*
|
||||
* Bridge form <-> JSON tab: when leaving the JSON tab back to Basic, push
|
||||
* any edits into form state. When entering JSON tab, snapshot current
|
||||
* form values so the user sees the live shape.
|
||||
*/
|
||||
function applyJsonToForm(): boolean {
|
||||
if (!jsonDirty) return true;
|
||||
const raw = jsonText.trim();
|
||||
@@ -275,8 +282,7 @@ export default function OutboundFormModal({
|
||||
return false;
|
||||
}
|
||||
const next = rawOutboundToFormValues(parsed);
|
||||
form.resetFields();
|
||||
form.setFieldsValue(next);
|
||||
methods.reset(next);
|
||||
setJsonDirty(false);
|
||||
return true;
|
||||
}
|
||||
@@ -290,7 +296,7 @@ export default function OutboundFormModal({
|
||||
|
||||
function onTabChange(key: string) {
|
||||
if (key === '2') {
|
||||
const values = form.getFieldsValue(true) as OutboundFormValues;
|
||||
const values = methods.getValues();
|
||||
setJsonText(JSON.stringify(formValuesToWirePayload(values), null, 2));
|
||||
setJsonDirty(false);
|
||||
switchTab(key);
|
||||
@@ -315,16 +321,11 @@ export default function OutboundFormModal({
|
||||
return;
|
||||
}
|
||||
values = rawOutboundToFormValues(parsed);
|
||||
form.resetFields();
|
||||
form.setFieldsValue(values);
|
||||
methods.reset(values);
|
||||
setJsonDirty(false);
|
||||
} else {
|
||||
try {
|
||||
await form.validateFields();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
values = form.getFieldsValue(true) as OutboundFormValues;
|
||||
if (!(await methods.trigger())) return;
|
||||
values = methods.getValues();
|
||||
}
|
||||
const tagValue = (values.tag ?? '').trim();
|
||||
if (!tagValue) {
|
||||
@@ -354,218 +355,226 @@ export default function OutboundFormModal({
|
||||
onCancel={onClose}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
colon={false}
|
||||
labelCol={{ md: { span: 8 } }}
|
||||
wrapperCol={{ md: { span: 14 } }}
|
||||
labelWrap
|
||||
onValuesChange={onValuesChange}
|
||||
>
|
||||
<Tabs
|
||||
activeKey={activeKey}
|
||||
onChange={onTabChange}
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: t('pages.xray.basicTemplate'),
|
||||
children: (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('protocol')}
|
||||
name="protocol"
|
||||
rules={[antdRule(OutboundFormBaseSchema.shape.tag, t)]}
|
||||
>
|
||||
<Select options={PROTOCOL_OPTIONS} />
|
||||
</Form.Item>
|
||||
<FormProvider {...methods}>
|
||||
<Form
|
||||
colon={false}
|
||||
labelCol={{ md: { span: 8 } }}
|
||||
wrapperCol={{ md: { span: 14 } }}
|
||||
labelWrap
|
||||
>
|
||||
<Tabs
|
||||
activeKey={activeKey}
|
||||
onChange={onTabChange}
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: t('pages.xray.basicTemplate'),
|
||||
children: (
|
||||
<>
|
||||
<FormField
|
||||
label={t('protocol')}
|
||||
name="protocol"
|
||||
rules={{ validate: rhfZodValidate(OutboundFormBaseSchema.shape.tag) }}
|
||||
>
|
||||
<Select id="protocol" options={PROTOCOL_OPTIONS} />
|
||||
</FormField>
|
||||
|
||||
<Form.Item
|
||||
label={t('pages.xray.outbound.tag')}
|
||||
name="tag"
|
||||
validateStatus={duplicateTag ? 'warning' : undefined}
|
||||
help={duplicateTag ? t('pages.xray.outboundForm.tagDuplicate') : undefined}
|
||||
rules={[
|
||||
{ required: true, message: t('pages.xray.outboundForm.tagRequired') },
|
||||
]}
|
||||
>
|
||||
<Input placeholder={t('pages.xray.outboundForm.tagPlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('pages.xray.outbound.sendThrough')} name="sendThrough">
|
||||
<Input placeholder={t('pages.xray.outboundForm.localIpPlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('pages.xray.outbound.targetStrategy')}
|
||||
name="targetStrategy"
|
||||
tooltip={t('pages.xray.outboundForm.targetStrategyHint')}
|
||||
>
|
||||
<Select allowClear placeholder="AsIs" options={TARGET_STRATEGY_OPTIONS} />
|
||||
</Form.Item>
|
||||
|
||||
{SERVER_PROTOCOLS.has(protocol) && <ServerTarget />}
|
||||
{protocol === 'vmess' && <VmessFields />}
|
||||
{protocol === 'vless' && <VlessFields />}
|
||||
{protocol === 'trojan' && <TrojanFields />}
|
||||
{protocol === 'shadowsocks' && <ShadowsocksFields />}
|
||||
{protocol === 'http' && <HttpFields />}
|
||||
{protocol === 'socks' && <SocksFields />}
|
||||
|
||||
{protocol === 'loopback' && <LoopbackFields />}
|
||||
{protocol === 'blackhole' && <BlackholeFields />}
|
||||
{protocol === 'dns' && <DnsFields />}
|
||||
|
||||
{protocol === 'freedom' && <FreedomFields form={form} />}
|
||||
|
||||
{protocol === 'vless' && (
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{() => {
|
||||
const reverseTag = form.getFieldValue(['settings', 'reverseTag']);
|
||||
if (!reverseTag) return null;
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name="tag"
|
||||
rules={{ required: 'pages.xray.outboundForm.tagRequired' }}
|
||||
render={({ field, fieldState }) => {
|
||||
const errorMessage = fieldState.error?.message
|
||||
? t(fieldState.error.message, { defaultValue: fieldState.error.message })
|
||||
: '';
|
||||
return (
|
||||
<SniffingFields
|
||||
name={['settings', 'reverseSniffing']}
|
||||
form={form}
|
||||
enableLabel={t('pages.xray.outboundForm.reverseSniffing')}
|
||||
/>
|
||||
<Form.Item
|
||||
label={t('pages.xray.outbound.tag')}
|
||||
required
|
||||
validateStatus={errorMessage ? 'error' : duplicateTag ? 'warning' : undefined}
|
||||
help={errorMessage || (duplicateTag ? t('pages.xray.outboundForm.tagDuplicate') : undefined)}
|
||||
>
|
||||
<Input
|
||||
value={field.value}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
placeholder={t('pages.xray.outboundForm.tagPlaceholder')}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
)}
|
||||
/>
|
||||
|
||||
{protocol === 'wireguard' && <WireguardFields form={form} />}
|
||||
<FormField label={t('pages.xray.outbound.sendThrough')} name="sendThrough">
|
||||
<Input placeholder={t('pages.xray.outboundForm.localIpPlaceholder')} />
|
||||
</FormField>
|
||||
|
||||
{streamAllowed && network && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('transmission')}
|
||||
name={['streamSettings', 'network']}
|
||||
>
|
||||
<Select
|
||||
value={network}
|
||||
onChange={onNetworkChange}
|
||||
options={
|
||||
protocol === 'hysteria'
|
||||
? [HYSTERIA_NETWORK_OPTION]
|
||||
: NETWORK_OPTIONS
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<FormField
|
||||
label={t('pages.xray.outbound.targetStrategy')}
|
||||
name="targetStrategy"
|
||||
tooltip={t('pages.xray.outboundForm.targetStrategyHint')}
|
||||
>
|
||||
<Select allowClear placeholder="AsIs" options={TARGET_STRATEGY_OPTIONS} />
|
||||
</FormField>
|
||||
|
||||
{network === 'tcp' && <RawForm form={form} />}
|
||||
{SERVER_PROTOCOLS.has(protocol) && <ServerTarget />}
|
||||
{protocol === 'vmess' && <VmessFields />}
|
||||
{protocol === 'vless' && <VlessFields />}
|
||||
{protocol === 'trojan' && <TrojanFields />}
|
||||
{protocol === 'shadowsocks' && <ShadowsocksFields />}
|
||||
{protocol === 'http' && <HttpFields />}
|
||||
{protocol === 'socks' && <SocksFields />}
|
||||
|
||||
{network === 'kcp' && <KcpForm />}
|
||||
{protocol === 'loopback' && <LoopbackFields />}
|
||||
{protocol === 'blackhole' && <BlackholeFields />}
|
||||
{protocol === 'dns' && <DnsFields />}
|
||||
|
||||
{network === 'ws' && <WsForm />}
|
||||
{protocol === 'freedom' && <FreedomFields />}
|
||||
|
||||
{network === 'grpc' && <GrpcForm />}
|
||||
|
||||
{network === 'httpupgrade' && <HttpUpgradeForm />}
|
||||
|
||||
{network === 'xhttp' && <XhttpForm form={form} onXmuxToggle={onXmuxToggle} />}
|
||||
|
||||
{network === 'hysteria' && <HysteriaForm form={form} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tlsFlowAllowed && (
|
||||
<Form.Item label={t('pages.clients.flow')} name={['settings', 'flow']}>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder={t('none')}
|
||||
options={[{ value: '', label: t('none') }, ...FLOW_OPTIONS]}
|
||||
{protocol === 'vless' && reverseTag && (
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name="settings.reverseSniffing"
|
||||
render={({ field }) => (
|
||||
<SniffingField
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
enableLabel={t('pages.xray.outboundForm.reverseSniffing')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Vision seed knobs only meaningful for the exact
|
||||
xtls-rprx-vision flow, on TCP+(tls|reality). The
|
||||
legacy class gated this on `canEnableVisionSeed()`
|
||||
— same condition encoded inline here. */}
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{() => {
|
||||
const flow =
|
||||
(form.getFieldValue(['settings', 'flow']) ?? '') as string;
|
||||
if (!(tlsFlowAllowed && flow === 'xtls-rprx-vision')) return null;
|
||||
return (
|
||||
<>
|
||||
<Form.Item label={t('pages.xray.outboundForm.visionTestpre')} name={['settings', 'testpre']}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.inbounds.form.visionTestseed')}>
|
||||
<Space.Compact block>
|
||||
{[900, 500, 900, 256].map((def, i) => (
|
||||
<Form.Item key={i} name={['settings', 'testseed', i]} noStyle initialValue={def}>
|
||||
<InputNumber min={1} style={{ width: '25%' }} />
|
||||
</Form.Item>
|
||||
))}
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
{protocol === 'wireguard' && <WireguardFields />}
|
||||
|
||||
{streamAllowed && network && (
|
||||
<Form.Item label={t('security')}>
|
||||
<Radio.Group
|
||||
value={security}
|
||||
buttonStyle="solid"
|
||||
onChange={(e) => onSecurityChange(e.target.value as string)}
|
||||
>
|
||||
{network !== 'hysteria' && <Radio.Button value="none">{t('none')}</Radio.Button>}
|
||||
{tlsAllowed && <Radio.Button value="tls">TLS</Radio.Button>}
|
||||
{realityAllowed && <Radio.Button value="reality">Reality</Radio.Button>}
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
)}
|
||||
{streamAllowed && network && (
|
||||
<>
|
||||
<Form.Item label={t('transmission')}>
|
||||
<Select
|
||||
value={network}
|
||||
onChange={onNetworkChange}
|
||||
options={
|
||||
protocol === 'hysteria'
|
||||
? [HYSTERIA_NETWORK_OPTION]
|
||||
: NETWORK_OPTIONS
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{security === 'tls' && tlsAllowed && <TlsForm />}
|
||||
{network === 'tcp' && <RawForm />}
|
||||
|
||||
{security === 'reality' && realityAllowed && <RealityForm />}
|
||||
{network === 'kcp' && <KcpForm />}
|
||||
|
||||
{((streamAllowed && network) || !streamAllowed || protocol === 'wireguard') && (
|
||||
<SockoptForm form={form} outboundTags={dialerProxyTags ?? existingTags} />
|
||||
)}
|
||||
{network === 'ws' && <WsForm />}
|
||||
|
||||
<FinalMaskForm
|
||||
name={['streamSettings', 'finalmask']}
|
||||
network={network}
|
||||
protocol={protocol}
|
||||
form={form}
|
||||
/>
|
||||
{network === 'grpc' && <GrpcForm />}
|
||||
|
||||
<MuxForm form={form} protocol={protocol} network={network} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
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:// wireguard://"
|
||||
enterButton="Import"
|
||||
onChange={(e) => setLinkInput(e.target.value)}
|
||||
onSearch={importLink}
|
||||
/>
|
||||
<JsonEditor
|
||||
value={jsonText}
|
||||
onChange={(next) => {
|
||||
setJsonText(next);
|
||||
setJsonDirty(true);
|
||||
}}
|
||||
minHeight="360px"
|
||||
maxHeight="600px"
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form>
|
||||
{network === 'httpupgrade' && <HttpUpgradeForm />}
|
||||
|
||||
{network === 'xhttp' && <XhttpForm onXmuxToggle={onXmuxToggle} />}
|
||||
|
||||
{network === 'hysteria' && <HysteriaForm />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tlsFlowAllowed && (
|
||||
<FormField label={t('pages.clients.flow')} name={['settings', 'flow']}>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder={t('none')}
|
||||
options={[{ value: '', label: t('none') }, ...FLOW_OPTIONS]}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{/* Vision seed knobs only meaningful for the exact
|
||||
xtls-rprx-vision flow, on TCP+(tls|reality). */}
|
||||
{tlsFlowAllowed && flow === 'xtls-rprx-vision' && (
|
||||
<>
|
||||
<FormField label={t('pages.xray.outboundForm.visionTestpre')} name={['settings', 'testpre']}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
<Form.Item label={t('pages.inbounds.form.visionTestseed')}>
|
||||
<Space.Compact block>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<FormField key={i} name={['settings', 'testseed', i]} noStyle>
|
||||
<InputNumber min={1} style={{ width: '25%' }} />
|
||||
</FormField>
|
||||
))}
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{streamAllowed && network && (
|
||||
<Form.Item label={t('security')}>
|
||||
<Radio.Group
|
||||
value={security}
|
||||
buttonStyle="solid"
|
||||
onChange={(e) => onSecurityChange(e.target.value as string)}
|
||||
>
|
||||
{network !== 'hysteria' && <Radio.Button value="none">{t('none')}</Radio.Button>}
|
||||
{tlsAllowed && <Radio.Button value="tls">TLS</Radio.Button>}
|
||||
{realityAllowed && <Radio.Button value="reality">Reality</Radio.Button>}
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{security === 'tls' && tlsAllowed && <TlsForm />}
|
||||
|
||||
{security === 'reality' && realityAllowed && <RealityForm />}
|
||||
|
||||
{((streamAllowed && network) || !streamAllowed || protocol === 'wireguard') && (
|
||||
<SockoptForm outboundTags={dialerProxyTags ?? existingTags} />
|
||||
)}
|
||||
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name="streamSettings.finalmask"
|
||||
render={({ field }) => (
|
||||
<FinalMaskField
|
||||
key={`${protocol}:${network}`}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
network={network}
|
||||
protocol={protocol}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<MuxForm protocol={protocol} network={network} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
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:// wireguard://"
|
||||
enterButton="Import"
|
||||
onChange={(e) => setLinkInput(e.target.value)}
|
||||
onSearch={importLink}
|
||||
/>
|
||||
<JsonEditor
|
||||
value={jsonText}
|
||||
onChange={(next) => {
|
||||
setJsonText(next);
|
||||
setJsonDirty(true);
|
||||
}}
|
||||
minHeight="360px"
|
||||
maxHeight="600px"
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Select } from 'antd';
|
||||
import { Select } from 'antd';
|
||||
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
export default function BlackholeFields() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Form.Item label={t('pages.xray.outboundForm.responseType')} name={['settings', 'type']}>
|
||||
<FormField label={t('pages.xray.outboundForm.responseType')} name={['settings', 'type']}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: '(empty)' },
|
||||
@@ -12,6 +14,6 @@ export default function BlackholeFields() {
|
||||
{ value: 'http', label: 'http' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, InputNumber, Select } from 'antd';
|
||||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import { DNSRuleActions } from '@/schemas/primitives';
|
||||
|
||||
export default function DnsFields() {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext();
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'settings.rules' });
|
||||
return (
|
||||
<>
|
||||
<Form.Item label={t('pages.xray.outboundForm.rewriteNetwork')} name={['settings', 'rewriteNetwork']}>
|
||||
<FormField label={t('pages.xray.outboundForm.rewriteNetwork')} name={['settings', 'rewriteNetwork']}>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder={t('pages.xray.outboundForm.unchanged')}
|
||||
@@ -18,62 +22,56 @@ export default function DnsFields() {
|
||||
{ value: 'tcp', label: 'tcp' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.inbounds.form.rewriteAddress')} name={['settings', 'rewriteAddress']}>
|
||||
</FormField>
|
||||
<FormField label={t('pages.inbounds.form.rewriteAddress')} name={['settings', 'rewriteAddress']}>
|
||||
<Input placeholder={t('pages.xray.outboundForm.unchangedAddress')} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.inbounds.form.rewritePort')} name={['settings', 'rewritePort']}>
|
||||
</FormField>
|
||||
<FormField label={t('pages.inbounds.form.rewritePort')} name={['settings', 'rewritePort']}>
|
||||
<InputNumber min={0} max={65535} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.tun.userLevel')} name={['settings', 'userLevel']}>
|
||||
</FormField>
|
||||
<FormField label={t('pages.xray.tun.userLevel')} name={['settings', 'userLevel']}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
<Form.Item label={t('pages.xray.outboundForm.rules')}>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() => append({ action: 'direct', qType: '', domain: '', rCode: 0 })}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.List name={['settings', 'rules']}>
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
<Form.Item label={t('pages.xray.outboundForm.rules')}>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() => add({ action: 'direct', qType: '', domain: '', rCode: 0 })}
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id}>
|
||||
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
|
||||
<div className="item-heading">
|
||||
<span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span>
|
||||
<DeleteOutlined
|
||||
className="danger-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('remove')}
|
||||
onClick={() => remove(index)}
|
||||
onKeyDown={activateOnKey(() => remove(index))}
|
||||
/>
|
||||
</Form.Item>
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.key}>
|
||||
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
|
||||
<div className="item-heading">
|
||||
<span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span>
|
||||
<DeleteOutlined
|
||||
className="danger-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('remove')}
|
||||
onClick={() => remove(field.name)}
|
||||
onKeyDown={activateOnKey(() => remove(field.name))}
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.outboundForm.action')} name={[field.name, 'action']}>
|
||||
<Select
|
||||
options={DNSRuleActions.map((a) => ({ value: a, label: a }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="QType" name={[field.name, 'qType']}>
|
||||
<Input placeholder="1,3,23-24" />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('domainName')} name={[field.name, 'domain']}>
|
||||
<Input placeholder="domain:example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item label="RCode" name={[field.name, 'rCode']}>
|
||||
<InputNumber min={0} max={65535} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</div>
|
||||
</Form.Item>
|
||||
<FormField label={t('pages.xray.outboundForm.action')} name={['settings', 'rules', index, 'action']}>
|
||||
<Select
|
||||
options={DNSRuleActions.map((a) => ({ value: a, label: a }))}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="QType" name={['settings', 'rules', index, 'qType']}>
|
||||
<Input placeholder="1,3,23-24" />
|
||||
</FormField>
|
||||
<FormField label={t('domainName')} name={['settings', 'rules', index, 'domain']}>
|
||||
<Input placeholder="domain:example.com" />
|
||||
</FormField>
|
||||
<FormField label="RCode" name={['settings', 'rules', index, 'rCode']}>
|
||||
<InputNumber min={0} max={65535} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,56 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AutoComplete, Button, Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
|
||||
import { AutoComplete, Button, Form, Input, InputNumber, Select, Switch } from 'antd';
|
||||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import { OutboundDomainStrategies } from '@/schemas/primitives';
|
||||
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
|
||||
|
||||
export default function FreedomFields({ form }: { form: FormInstance<OutboundFormValues> }) {
|
||||
interface FragmentValue {
|
||||
packets?: string;
|
||||
length?: string;
|
||||
interval?: string;
|
||||
maxSplit?: string;
|
||||
}
|
||||
|
||||
export default function FreedomFields() {
|
||||
const { t } = useTranslation();
|
||||
const { control, setValue } = useFormContext();
|
||||
|
||||
const fragment = (useWatch({ control, name: 'settings.fragment' }) ?? {}) as FragmentValue;
|
||||
const fragmentEnabled = !!(fragment.length || fragment.interval || fragment.maxSplit);
|
||||
|
||||
const {
|
||||
fields: noiseFields,
|
||||
append: appendNoise,
|
||||
remove: removeNoise,
|
||||
} = useFieldArray({ control, name: 'settings.noises' });
|
||||
|
||||
const {
|
||||
fields: finalRuleFields,
|
||||
append: appendFinalRule,
|
||||
remove: removeFinalRule,
|
||||
} = useFieldArray({ control, name: 'settings.finalRules' });
|
||||
const finalRulesValues = (useWatch({ control, name: 'settings.finalRules' }) ?? []) as { action?: string }[];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item label={t('pages.xray.balancer.balancerStrategy')} name={['settings', 'domainStrategy']}>
|
||||
<FormField label={t('pages.xray.balancer.balancerStrategy')} name={['settings', 'domainStrategy']}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: `(${t('none')})` },
|
||||
...OutboundDomainStrategies.map((s) => ({ value: s, label: s })),
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.outboundForm.redirect')} name={['settings', 'redirect']}>
|
||||
</FormField>
|
||||
<FormField label={t('pages.xray.outboundForm.redirect')} name={['settings', 'redirect']}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.tun.userLevel')} name={['settings', 'userLevel']}>
|
||||
</FormField>
|
||||
<FormField label={t('pages.xray.tun.userLevel')} name={['settings', 'userLevel']}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.outboundForm.proxyProtocol')} name={['settings', 'proxyProtocol']}>
|
||||
</FormField>
|
||||
<FormField label={t('pages.xray.outboundForm.proxyProtocol')} name={['settings', 'proxyProtocol']}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 0, label: `(${t('none')})` },
|
||||
@@ -32,260 +58,190 @@ export default function FreedomFields({ form }: { form: FormInstance<OutboundFor
|
||||
{ value: 2, label: 'v2' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
|
||||
<Form.Item label={t('pages.xray.outboundForm.fragment')} shouldUpdate noStyle>
|
||||
{() => {
|
||||
const fragment = (form.getFieldValue(['settings', 'fragment']) ?? {}) as {
|
||||
packets?: string;
|
||||
length?: string;
|
||||
interval?: string;
|
||||
maxSplit?: string;
|
||||
};
|
||||
const enabled = !!(fragment.length || fragment.interval || fragment.maxSplit);
|
||||
return (
|
||||
<>
|
||||
<Form.Item label="Fragment">
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={(checked) => {
|
||||
form.setFieldValue(
|
||||
['settings', 'fragment'],
|
||||
checked
|
||||
? {
|
||||
packets: 'tlshello',
|
||||
length: '100-200',
|
||||
interval: '10-20',
|
||||
maxSplit: '300-400',
|
||||
}
|
||||
: { packets: '', length: '', interval: '', maxSplit: '' },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{enabled && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.settings.subFormats.packets')}
|
||||
name={['settings', 'fragment', 'packets']}
|
||||
rules={[{
|
||||
validator: (_rule, value) => {
|
||||
const str = String(value ?? '').trim();
|
||||
// xray accepts "tlshello" or any packet-number range (#5075)
|
||||
if (str === '' || str === 'tlshello' || /^\d+-\d+$/.test(str)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('Use "tlshello" or a packet range like 1-3'));
|
||||
},
|
||||
}]}
|
||||
>
|
||||
<AutoComplete
|
||||
options={[
|
||||
{ value: 'tlshello', label: 'tlshello' },
|
||||
{ value: '1-3', label: '1-3' },
|
||||
{ value: '1-5', label: '1-5' },
|
||||
]}
|
||||
placeholder="tlshello or n-m, e.g. 1-3"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.settings.subFormats.length')} name={['settings', 'fragment', 'length']}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.settings.subFormats.interval')}
|
||||
name={['settings', 'fragment', 'interval']}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.settings.subFormats.maxSplit')}
|
||||
name={['settings', 'fragment', 'maxSplit']}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
<Form.List name={['settings', 'noises']}>
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
<Form.Item label={t('pages.settings.subFormats.noises')}>
|
||||
<Switch
|
||||
checked={fields.length > 0}
|
||||
onChange={(checked) => {
|
||||
if (checked) {
|
||||
add({
|
||||
type: 'rand',
|
||||
packet: '10-20',
|
||||
delay: '10-16',
|
||||
applyTo: 'ip',
|
||||
});
|
||||
} else {
|
||||
// remove() with no arg is not supported;
|
||||
// walk fields in reverse and drop each.
|
||||
for (let i = fields.length - 1; i >= 0; i--) {
|
||||
remove(fields[i].name);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{fields.length > 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="ml-8"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() =>
|
||||
add({
|
||||
type: 'rand',
|
||||
packet: '10-20',
|
||||
delay: '10-16',
|
||||
applyTo: 'ip',
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Form.Item>
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.key}>
|
||||
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
|
||||
<div className="item-heading">
|
||||
<span>{t('pages.settings.subFormats.noiseItem', { n: index + 1 })}</span>
|
||||
{fields.length > 1 && (
|
||||
<DeleteOutlined
|
||||
className="danger-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('remove')}
|
||||
onClick={() => remove(field.name)}
|
||||
onKeyDown={activateOnKey(() => remove(field.name))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.settings.subFormats.type')} name={[field.name, 'type']}>
|
||||
<Select
|
||||
options={['rand', 'base64', 'str', 'hex'].map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.settings.subFormats.packet')} name={[field.name, 'packet']}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.settings.subFormats.delayMs')} name={[field.name, 'delay']}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.settings.subFormats.applyTo')} name={[field.name, 'applyTo']}>
|
||||
<Select
|
||||
options={['ip', 'ipv4', 'ipv6'].map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
|
||||
<Form.List name={['settings', 'finalRules']}>
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
<Form.Item label={t('pages.xray.outboundForm.finalRules')}>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() =>
|
||||
add({
|
||||
action: 'allow',
|
||||
network: '',
|
||||
port: '',
|
||||
ip: [],
|
||||
blockDelay: '',
|
||||
})
|
||||
<Form.Item label="Fragment">
|
||||
<Switch
|
||||
checked={fragmentEnabled}
|
||||
onChange={(checked) => {
|
||||
setValue(
|
||||
'settings.fragment',
|
||||
checked
|
||||
? {
|
||||
packets: 'tlshello',
|
||||
length: '100-200',
|
||||
interval: '10-20',
|
||||
maxSplit: '300-400',
|
||||
}
|
||||
/>
|
||||
<span className="ml-8" style={{ opacity: 0.6 }}>
|
||||
{t('pages.xray.outboundForm.overrideXrayPrivateIp')}
|
||||
</span>
|
||||
</Form.Item>
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.key}>
|
||||
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
|
||||
<div className="item-heading">
|
||||
<span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span>
|
||||
<DeleteOutlined
|
||||
className="danger-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('remove')}
|
||||
onClick={() => remove(field.name)}
|
||||
onKeyDown={activateOnKey(() => remove(field.name))}
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.outboundForm.action')} name={[field.name, 'action']}>
|
||||
<Select
|
||||
options={['allow', 'block'].map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.inbounds.network')} name={[field.name, 'network']}>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="(any)"
|
||||
options={['tcp', 'udp', 'tcp,udp'].map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.inbounds.port')} name={[field.name, 'port']}>
|
||||
<Input placeholder="e.g. 80,443 or 1000-2000" />
|
||||
</Form.Item>
|
||||
<Form.Item label="IP / CIDR / geoip" name={[field.name, 'ip']}>
|
||||
<Select
|
||||
mode="tags"
|
||||
tokenSeparators={[',', ' ']}
|
||||
placeholder="e.g. 10.0.0.0/8, geoip:private"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{() => {
|
||||
const ruleAction = form.getFieldValue([
|
||||
'settings',
|
||||
'finalRules',
|
||||
field.name,
|
||||
'action',
|
||||
]);
|
||||
if (ruleAction !== 'block') return null;
|
||||
return (
|
||||
<Form.Item
|
||||
label={t('pages.xray.outboundForm.blockDelay')}
|
||||
name={[field.name, 'blockDelay']}
|
||||
>
|
||||
<Input placeholder="optional: 5000-10000" />
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
: { packets: '', length: '', interval: '', maxSplit: '' },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{fragmentEnabled && (
|
||||
<>
|
||||
<FormField
|
||||
label={t('pages.settings.subFormats.packets')}
|
||||
name={['settings', 'fragment', 'packets']}
|
||||
rules={{
|
||||
validate: (value) => {
|
||||
const str = String(value ?? '').trim();
|
||||
/* xray accepts "tlshello" or any packet-number range (#5075) */
|
||||
if (str === '' || str === 'tlshello' || /^\d+-\d+$/.test(str)) return true;
|
||||
return 'Use "tlshello" or a packet range like 1-3';
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AutoComplete
|
||||
options={[
|
||||
{ value: 'tlshello', label: 'tlshello' },
|
||||
{ value: '1-3', label: '1-3' },
|
||||
{ value: '1-5', label: '1-5' },
|
||||
]}
|
||||
placeholder="tlshello or n-m, e.g. 1-3"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t('pages.settings.subFormats.length')} name={['settings', 'fragment', 'length']}>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField label={t('pages.settings.subFormats.interval')} name={['settings', 'fragment', 'interval']}>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField label={t('pages.settings.subFormats.maxSplit')} name={['settings', 'fragment', 'maxSplit']}>
|
||||
<Input />
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Form.Item label={t('pages.settings.subFormats.noises')}>
|
||||
<Switch
|
||||
checked={noiseFields.length > 0}
|
||||
onChange={(checked) => {
|
||||
if (checked) {
|
||||
appendNoise({ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' });
|
||||
} else {
|
||||
removeNoise();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{noiseFields.length > 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="ml-8"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() => appendNoise({ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' })}
|
||||
/>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form.Item>
|
||||
{noiseFields.map((field, index) => (
|
||||
<div key={field.id}>
|
||||
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
|
||||
<div className="item-heading">
|
||||
<span>{t('pages.settings.subFormats.noiseItem', { n: index + 1 })}</span>
|
||||
{noiseFields.length > 1 && (
|
||||
<DeleteOutlined
|
||||
className="danger-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('remove')}
|
||||
onClick={() => removeNoise(index)}
|
||||
onKeyDown={activateOnKey(() => removeNoise(index))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Form.Item>
|
||||
<FormField label={t('pages.settings.subFormats.type')} name={['settings', 'noises', index, 'type']}>
|
||||
<Select
|
||||
options={['rand', 'base64', 'str', 'hex'].map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t('pages.settings.subFormats.packet')} name={['settings', 'noises', index, 'packet']}>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField label={t('pages.settings.subFormats.delayMs')} name={['settings', 'noises', index, 'delay']}>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField label={t('pages.settings.subFormats.applyTo')} name={['settings', 'noises', index, 'applyTo']}>
|
||||
<Select
|
||||
options={['ip', 'ipv4', 'ipv6'].map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Form.Item label={t('pages.xray.outboundForm.finalRules')}>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() => appendFinalRule({ action: 'allow', network: '', port: '', ip: [], blockDelay: '' })}
|
||||
/>
|
||||
<span className="ml-8" style={{ opacity: 0.6 }}>
|
||||
{t('pages.xray.outboundForm.overrideXrayPrivateIp')}
|
||||
</span>
|
||||
</Form.Item>
|
||||
{finalRuleFields.map((field, index) => (
|
||||
<div key={field.id}>
|
||||
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
|
||||
<div className="item-heading">
|
||||
<span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span>
|
||||
<DeleteOutlined
|
||||
className="danger-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('remove')}
|
||||
onClick={() => removeFinalRule(index)}
|
||||
onKeyDown={activateOnKey(() => removeFinalRule(index))}
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
<FormField label={t('pages.xray.outboundForm.action')} name={['settings', 'finalRules', index, 'action']}>
|
||||
<Select
|
||||
options={['allow', 'block'].map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t('pages.inbounds.network')} name={['settings', 'finalRules', index, 'network']}>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="(any)"
|
||||
options={['tcp', 'udp', 'tcp,udp'].map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t('pages.inbounds.port')} name={['settings', 'finalRules', index, 'port']}>
|
||||
<Input placeholder="e.g. 80,443 or 1000-2000" />
|
||||
</FormField>
|
||||
<FormField label="IP / CIDR / geoip" name={['settings', 'finalRules', index, 'ip']}>
|
||||
<Select
|
||||
mode="tags"
|
||||
tokenSeparators={[',', ' ']}
|
||||
placeholder="e.g. 10.0.0.0/8, geoip:private"
|
||||
/>
|
||||
</FormField>
|
||||
{finalRulesValues[index]?.action === 'block' && (
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.blockDelay')}
|
||||
name={['settings', 'finalRules', index, 'blockDelay']}
|
||||
>
|
||||
<Input placeholder="optional: 5000-10000" />
|
||||
</FormField>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input } from 'antd';
|
||||
import { Input } from 'antd';
|
||||
|
||||
import { HeaderMapEditor } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
export default function HttpFields() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Form.Item label={t('username')} name={['settings', 'user']}>
|
||||
<FormField label={t('username')} name={['settings', 'user']}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('password')} name={['settings', 'pass']}>
|
||||
</FormField>
|
||||
<FormField label={t('password')} name={['settings', 'pass']}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.inbounds.form.headers')} name={['settings', 'headers']}>
|
||||
</FormField>
|
||||
<FormField label={t('pages.inbounds.form.headers')} name={['settings', 'headers']}>
|
||||
<HeaderMapEditor mode="v1" />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input } from 'antd';
|
||||
import { Input } from 'antd';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
|
||||
import SniffingFields from '@/lib/xray/forms/SniffingFields';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { SniffingField } from '@/lib/xray/forms/fields';
|
||||
|
||||
export default function LoopbackFields() {
|
||||
const { t } = useTranslation();
|
||||
const form = Form.useFormInstance();
|
||||
const { control } = useFormContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item label={t('pages.xray.outboundForm.inboundTag')} name={['settings', 'inboundTag']}>
|
||||
<FormField label={t('pages.xray.outboundForm.inboundTag')} name={['settings', 'inboundTag']}>
|
||||
<Input placeholder={t('pages.xray.outboundForm.inboundTagPlaceholder')} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
|
||||
<SniffingFields
|
||||
name={['settings', 'sniffing']}
|
||||
form={form}
|
||||
enableLabel={t('pages.inbounds.sniffingTab')}
|
||||
<Controller
|
||||
control={control}
|
||||
name="settings.sniffing"
|
||||
render={({ field }) => (
|
||||
<SniffingField
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
enableLabel={t('pages.inbounds.sniffingTab')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, InputNumber } from 'antd';
|
||||
import { Input, InputNumber } from 'antd';
|
||||
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
export default function ServerTarget() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
<FormField
|
||||
label={t('pages.inbounds.address')}
|
||||
name={['settings', 'address']}
|
||||
rules={[{ required: true, message: t('pages.xray.outboundForm.addressRequired') }]}
|
||||
required
|
||||
rules={{ required: 'pages.xray.outboundForm.addressRequired' }}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.port')}
|
||||
name={['settings', 'port']}
|
||||
rules={[{ required: true, message: t('pages.xray.outboundForm.portRequired') }]}
|
||||
required
|
||||
rules={{ required: 'pages.xray.outboundForm.portRequired' }}
|
||||
>
|
||||
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, InputNumber, Select, Switch } from 'antd';
|
||||
import { Input, InputNumber, Select, Switch } from 'antd';
|
||||
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import { ShadowsocksOutboundFormSettingsSchema } from '@/schemas/forms/outbound-form';
|
||||
import { SSMethodSchema } from '@/schemas/protocols/shared/shadowsocks';
|
||||
import { antdRule } from '@/utils/zodForm';
|
||||
|
||||
import { SS_METHOD_OPTIONS } from '../outbound-form-constants';
|
||||
|
||||
@@ -11,30 +11,30 @@ export default function ShadowsocksFields() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
<FormField
|
||||
label={t('password')}
|
||||
name={['settings', 'password']}
|
||||
rules={[antdRule(ShadowsocksOutboundFormSettingsSchema.shape.password, t)]}
|
||||
rules={{ validate: rhfZodValidate(ShadowsocksOutboundFormSettingsSchema.shape.password) }}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('encryption')}
|
||||
name={['settings', 'method']}
|
||||
rules={[antdRule(SSMethodSchema, t)]}
|
||||
rules={{ validate: rhfZodValidate(SSMethodSchema) }}
|
||||
>
|
||||
<Select options={SS_METHOD_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.udpOverTcp')}
|
||||
name={['settings', 'uot']}
|
||||
valuePropName="checked"
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.outboundForm.uotVersion')} name={['settings', 'UoTVersion']}>
|
||||
</FormField>
|
||||
<FormField label={t('pages.xray.outboundForm.uotVersion')} name={['settings', 'UoTVersion']}>
|
||||
<InputNumber min={1} max={2} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input } from 'antd';
|
||||
import { Input } from 'antd';
|
||||
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
export default function SocksFields() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Form.Item label={t('username')} name={['settings', 'user']}>
|
||||
<FormField label={t('username')} name={['settings', 'user']}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('password')} name={['settings', 'pass']}>
|
||||
</FormField>
|
||||
<FormField label={t('password')} name={['settings', 'pass']}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input } from 'antd';
|
||||
import { Input } from 'antd';
|
||||
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import { TrojanOutboundFormSettingsSchema } from '@/schemas/forms/outbound-form';
|
||||
import { antdRule } from '@/utils/zodForm';
|
||||
|
||||
export default function TrojanFields() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Form.Item
|
||||
<FormField
|
||||
label={t('password')}
|
||||
name={['settings', 'password']}
|
||||
rules={[antdRule(TrojanOutboundFormSettingsSchema.shape.password, t)]}
|
||||
rules={{ validate: rhfZodValidate(TrojanOutboundFormSettingsSchema.shape.password) }}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input } from 'antd';
|
||||
import { Input } from 'antd';
|
||||
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import {
|
||||
VlessOutboundFormSettingsSchema,
|
||||
VmessOutboundFormSettingsSchema,
|
||||
} from '@/schemas/forms/outbound-form';
|
||||
import { antdRule } from '@/utils/zodForm';
|
||||
|
||||
export default function VlessFields() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
<FormField
|
||||
label="ID"
|
||||
name={['settings', 'id']}
|
||||
rules={[antdRule(VmessOutboundFormSettingsSchema.shape.id, t)]}
|
||||
rules={{ validate: rhfZodValidate(VmessOutboundFormSettingsSchema.shape.id) }}
|
||||
>
|
||||
<Input placeholder="UUID" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('encryption')}
|
||||
name={['settings', 'encryption']}
|
||||
rules={[antdRule(VlessOutboundFormSettingsSchema.shape.encryption, t)]}
|
||||
rules={{ validate: rhfZodValidate(VlessOutboundFormSettingsSchema.shape.encryption) }}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.clients.reverseTag')} name={['settings', 'reverseTag']}>
|
||||
</FormField>
|
||||
<FormField label={t('pages.clients.reverseTag')} name={['settings', 'reverseTag']}>
|
||||
<Input placeholder={t('pages.xray.outboundForm.optional')} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, Select } from 'antd';
|
||||
import { Input, Select } from 'antd';
|
||||
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import { VmessOutboundFormSettingsSchema } from '@/schemas/forms/outbound-form';
|
||||
import { antdRule } from '@/utils/zodForm';
|
||||
|
||||
import { SECURITY_OPTIONS } from '../outbound-form-constants';
|
||||
|
||||
@@ -10,20 +10,20 @@ export default function VmessFields() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
<FormField
|
||||
label="ID"
|
||||
name={['settings', 'id']}
|
||||
rules={[antdRule(VmessOutboundFormSettingsSchema.shape.id, t)]}
|
||||
rules={{ validate: rhfZodValidate(VmessOutboundFormSettingsSchema.shape.id) }}
|
||||
>
|
||||
<Input placeholder="UUID" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('security')}
|
||||
name={['settings', 'security']}
|
||||
rules={[antdRule(VmessOutboundFormSettingsSchema.shape.security, t)]}
|
||||
rules={{ validate: rhfZodValidate(VmessOutboundFormSettingsSchema.shape.security) }}
|
||||
>
|
||||
<Select options={SECURITY_OPTIONS} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,147 +1,149 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, InputNumber, Select, Space, Switch, type FormInstance } from 'antd';
|
||||
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
||||
import { DeleteOutlined, MinusOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { Wireguard } from '@/utils';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import { InputAddon } from '@/components/ui';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { WireguardDomainStrategy } from '@/schemas/primitives';
|
||||
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
|
||||
|
||||
export default function WireguardFields({ form }: { form: FormInstance<OutboundFormValues> }) {
|
||||
function AllowedIPsList({ peerIndex }: { peerIndex: number }) {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext();
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: `settings.peers.${peerIndex}.allowedIPs`,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Form.Item label={t('pages.inbounds.address')} name={['settings', 'address']}>
|
||||
{fields.map((field, ipIdx) => (
|
||||
<Space.Compact key={field.id} block style={{ marginBottom: 4 }}>
|
||||
<FormField noStyle name={['settings', 'peers', peerIndex, 'allowedIPs', ipIdx]}>
|
||||
<Input aria-label={t('pages.xray.wireguard.allowedIPs')} />
|
||||
</FormField>
|
||||
{fields.length > 1 && (
|
||||
<InputAddon ariaLabel={t('remove')} onClick={() => remove(ipIdx)}>
|
||||
<MinusOutlined />
|
||||
</InputAddon>
|
||||
)}
|
||||
</Space.Compact>
|
||||
))}
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() => append('')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WireguardFields() {
|
||||
const { t } = useTranslation();
|
||||
const { control, setValue } = useFormContext();
|
||||
const {
|
||||
fields: peerFields,
|
||||
append: appendPeer,
|
||||
remove: removePeer,
|
||||
} = useFieldArray({ control, name: 'settings.peers' });
|
||||
return (
|
||||
<>
|
||||
<FormField label={t('pages.inbounds.address')} name={['settings', 'address']}>
|
||||
<Input placeholder="comma-separated, e.g. 10.0.0.1,fd00::1" />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<Form.Item label={t('pages.inbounds.privatekey')}>
|
||||
<Space.Compact block>
|
||||
<Form.Item name={['settings', 'secretKey']} noStyle>
|
||||
<FormField name={['settings', 'secretKey']} noStyle>
|
||||
<Input aria-label={t('pages.inbounds.privatekey')} style={{ width: 'calc(100% - 32px)' }} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
aria-label={t('regenerate')}
|
||||
onClick={() => {
|
||||
const pair = Wireguard.generateKeypair();
|
||||
form.setFieldValue(['settings', 'secretKey'], pair.privateKey);
|
||||
form.setFieldValue(['settings', 'pubKey'], pair.publicKey);
|
||||
setValue('settings.secretKey', pair.privateKey);
|
||||
setValue('settings.pubKey', pair.publicKey);
|
||||
}}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.inbounds.publicKey')} name={['settings', 'pubKey']}>
|
||||
<FormField label={t('pages.inbounds.publicKey')} name={['settings', 'pubKey']}>
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.wireguard.domainStrategy')} name={['settings', 'domainStrategy']}>
|
||||
</FormField>
|
||||
<FormField label={t('pages.xray.wireguard.domainStrategy')} name={['settings', 'domainStrategy']}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: `(${t('none')})` },
|
||||
...WireguardDomainStrategy.map((s) => ({ value: s, label: s })),
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="MTU" name={['settings', 'mtu']}>
|
||||
</FormField>
|
||||
<FormField label="MTU" name={['settings', 'mtu']}>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.info.noKernelTun')}
|
||||
name={['settings', 'noKernelTun']}
|
||||
valuePropName="checked"
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.outboundForm.reserved')} name={['settings', 'reserved']}>
|
||||
</FormField>
|
||||
<FormField label={t('pages.xray.outboundForm.reserved')} name={['settings', 'reserved']}>
|
||||
<Input placeholder="comma-separated bytes, e.g. 1,2,3" />
|
||||
</FormField>
|
||||
<Form.Item label={t('pages.inbounds.form.peers')}>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() =>
|
||||
appendPeer({
|
||||
publicKey: '',
|
||||
psk: '',
|
||||
allowedIPs: ['0.0.0.0/0', '::/0'],
|
||||
endpoint: '',
|
||||
keepAlive: 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.List name={['settings', 'peers']}>
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
<Form.Item label={t('pages.inbounds.form.peers')}>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() =>
|
||||
add({
|
||||
publicKey: '',
|
||||
psk: '',
|
||||
allowedIPs: ['0.0.0.0/0', '::/0'],
|
||||
endpoint: '',
|
||||
keepAlive: 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.key}>
|
||||
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
|
||||
<div className="item-heading">
|
||||
<span>{t('pages.inbounds.info.peerNumber', { n: index + 1 })}</span>
|
||||
{fields.length > 1 && (
|
||||
<DeleteOutlined
|
||||
className="danger-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('remove')}
|
||||
onClick={() => remove(field.name)}
|
||||
onKeyDown={activateOnKey(() => remove(field.name))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.wireguard.endpoint')} name={[field.name, 'endpoint']}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.publicKey')}
|
||||
name={[field.name, 'publicKey']}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="PSK" name={[field.name, 'psk']}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.wireguard.allowedIPs')}>
|
||||
<Form.List name={[field.name, 'allowedIPs']}>
|
||||
{(ipFields, { add: addIp, remove: removeIp }) => (
|
||||
<>
|
||||
{ipFields.map((ipField, ipIdx) => (
|
||||
<Space.Compact
|
||||
key={ipField.key}
|
||||
block
|
||||
style={{ marginBottom: 4 }}
|
||||
>
|
||||
<Form.Item noStyle name={ipField.name}>
|
||||
<Input aria-label={t('pages.xray.wireguard.allowedIPs')} />
|
||||
</Form.Item>
|
||||
{ipFields.length > 1 && (
|
||||
<InputAddon ariaLabel={t('remove')} onClick={() => removeIp(ipIdx)}>
|
||||
<MinusOutlined />
|
||||
</InputAddon>
|
||||
)}
|
||||
</Space.Compact>
|
||||
))}
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label={t('add')}
|
||||
onClick={() => addIp('')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.inbounds.info.keepAlive')} name={[field.name, 'keepAlive']}>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
{peerFields.map((field, index) => (
|
||||
<div key={field.id}>
|
||||
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
|
||||
<div className="item-heading">
|
||||
<span>{t('pages.inbounds.info.peerNumber', { n: index + 1 })}</span>
|
||||
{peerFields.length > 1 && (
|
||||
<DeleteOutlined
|
||||
className="danger-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('remove')}
|
||||
onClick={() => removePeer(index)}
|
||||
onKeyDown={activateOnKey(() => removePeer(index))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Form.Item>
|
||||
<FormField label={t('pages.xray.wireguard.endpoint')} name={['settings', 'peers', index, 'endpoint']}>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField label={t('pages.inbounds.publicKey')} name={['settings', 'peers', index, 'publicKey']}>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField label="PSK" name={['settings', 'peers', index, 'psk']}>
|
||||
<Input />
|
||||
</FormField>
|
||||
<Form.Item label={t('pages.xray.wireguard.allowedIPs')}>
|
||||
<AllowedIPsList peerIndex={index} />
|
||||
</Form.Item>
|
||||
<FormField label={t('pages.inbounds.info.keepAlive')} name={['settings', 'peers', index, 'keepAlive']}>
|
||||
<InputNumber min={0} />
|
||||
</FormField>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, Select } from 'antd';
|
||||
import { Input, Select } from 'antd';
|
||||
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
import { UTLS_OPTIONS } from '../outbound-form-constants';
|
||||
|
||||
@@ -7,42 +9,42 @@ export default function RealityForm() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
<FormField
|
||||
label="SNI"
|
||||
name={['streamSettings', 'realitySettings', 'serverName']}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label="uTLS"
|
||||
name={['streamSettings', 'realitySettings', 'fingerprint']}
|
||||
>
|
||||
<Select options={UTLS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.shortId')}
|
||||
name={['streamSettings', 'realitySettings', 'shortId']}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.spiderX')}
|
||||
name={['streamSettings', 'realitySettings', 'spiderX']}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.publicKey')}
|
||||
name={['streamSettings', 'realitySettings', 'publicKey']}
|
||||
>
|
||||
<Input.TextArea autoSize={{ minRows: 2 }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.mldsa65Verify')}
|
||||
name={['streamSettings', 'realitySettings', 'mldsa65Verify']}
|
||||
>
|
||||
<Input.TextArea autoSize={{ minRows: 2 }} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, Select } from 'antd';
|
||||
import { Input, Select } from 'antd';
|
||||
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
import { ALPN_OPTIONS, UTLS_OPTIONS } from '../outbound-form-constants';
|
||||
|
||||
@@ -7,13 +9,13 @@ export default function TlsForm() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
<FormField
|
||||
label="SNI"
|
||||
name={['streamSettings', 'tlsSettings', 'serverName']}
|
||||
>
|
||||
<Input placeholder={t('pages.xray.outboundForm.serverNamePlaceholder')} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label="uTLS"
|
||||
name={['streamSettings', 'tlsSettings', 'fingerprint']}
|
||||
>
|
||||
@@ -22,31 +24,31 @@ export default function TlsForm() {
|
||||
placeholder={t('none')}
|
||||
options={[{ value: '', label: t('none') }, ...UTLS_OPTIONS]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label="ALPN"
|
||||
name={['streamSettings', 'tlsSettings', 'alpn']}
|
||||
>
|
||||
<Select mode="multiple" options={ALPN_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label="ECH"
|
||||
name={['streamSettings', 'tlsSettings', 'echConfigList']}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.verifyPeerName')}
|
||||
name={['streamSettings', 'tlsSettings', 'verifyPeerCertByName']}
|
||||
>
|
||||
<Input placeholder="cloudflare-dns.com" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.pinnedSha256')}
|
||||
name={['streamSettings', 'tlsSettings', 'pinnedPeerCertSha256']}
|
||||
>
|
||||
<Input placeholder="base64 SHA256" />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, Switch } from 'antd';
|
||||
import { Input, Switch } from 'antd';
|
||||
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
export default function GrpcForm() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.serviceName')}
|
||||
name={['streamSettings', 'grpcSettings', 'serviceName']}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.authority')}
|
||||
name={['streamSettings', 'grpcSettings', 'authority']}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.multiMode')}
|
||||
name={['streamSettings', 'grpcSettings', 'multiMode']}
|
||||
valuePropName="checked"
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,31 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input } from 'antd';
|
||||
import { Input } from 'antd';
|
||||
|
||||
import { HeaderMapEditor } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
export default function HttpUpgradeForm() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
<FormField
|
||||
label={t('host')}
|
||||
name={['streamSettings', 'httpupgradeSettings', 'host']}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('path')}
|
||||
name={['streamSettings', 'httpupgradeSettings', 'path']}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.headers')}
|
||||
name={['streamSettings', 'httpupgradeSettings', 'headers']}
|
||||
>
|
||||
<HeaderMapEditor mode="v1" />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,134 +1,108 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
|
||||
import { Form, Input, InputNumber, Select, Switch } from 'antd';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { HeaderMapEditor } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
const MASQ_PATH = ['streamSettings', 'hysteriaSettings', 'masquerade'];
|
||||
const MASQ = ['streamSettings', 'hysteriaSettings', 'masquerade'];
|
||||
const MASQ_DOT = 'streamSettings.hysteriaSettings.masquerade';
|
||||
|
||||
export default function HysteriaForm({ form }: { form: FormInstance }) {
|
||||
export default function HysteriaForm() {
|
||||
const { t } = useTranslation();
|
||||
const { control, setValue } = useFormContext();
|
||||
const masquerade = useWatch({ control, name: MASQ_DOT }) as { type?: string } | undefined;
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.version')}
|
||||
name={['streamSettings', 'hysteriaSettings', 'version']}
|
||||
>
|
||||
<InputNumber min={2} max={2} disabled style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.authPassword')}
|
||||
name={['streamSettings', 'hysteriaSettings', 'auth']}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.udpIdleTimeout')}
|
||||
name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']}
|
||||
>
|
||||
<InputNumber min={2} max={600} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
|
||||
<Form.Item label={t('pages.inbounds.form.masquerade')}>
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{() => {
|
||||
const m = form.getFieldValue(MASQ_PATH);
|
||||
return (
|
||||
<Switch
|
||||
checked={!!m}
|
||||
onChange={(checked) =>
|
||||
form.setFieldValue(
|
||||
MASQ_PATH,
|
||||
checked
|
||||
? {
|
||||
type: '', dir: '', url: '',
|
||||
rewriteHost: false, insecure: false,
|
||||
content: '', headers: {}, statusCode: 0,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
<Switch
|
||||
checked={!!masquerade}
|
||||
onChange={(checked) =>
|
||||
setValue(
|
||||
MASQ_DOT,
|
||||
checked
|
||||
? {
|
||||
type: '', dir: '', url: '',
|
||||
rewriteHost: false, insecure: false,
|
||||
content: '', headers: {}, statusCode: 0,
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{() => {
|
||||
const m = form.getFieldValue(MASQ_PATH) as { type?: string } | undefined;
|
||||
if (!m) return null;
|
||||
return (
|
||||
{masquerade && (
|
||||
<>
|
||||
<FormField label={t('pages.inbounds.form.type')} name={[...MASQ, 'type']}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'default (404 page)' },
|
||||
{ value: 'proxy', label: 'proxy (reverse proxy)' },
|
||||
{ value: 'file', label: 'file (serve directory)' },
|
||||
{ value: 'string', label: 'string (fixed body)' },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
{masquerade.type === 'proxy' && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.type')}
|
||||
name={[...MASQ_PATH, 'type']}
|
||||
<FormField label={t('pages.inbounds.form.upstreamUrl')} name={[...MASQ, 'url']}>
|
||||
<Input placeholder="https://www.example.com" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.rewriteHost')}
|
||||
name={[...MASQ, 'rewriteHost']}
|
||||
valueProp="checked"
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'default (404 page)' },
|
||||
{ value: 'proxy', label: 'proxy (reverse proxy)' },
|
||||
{ value: 'file', label: 'file (serve directory)' },
|
||||
{ value: 'string', label: 'string (fixed body)' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{m.type === 'proxy' && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.upstreamUrl')}
|
||||
name={[...MASQ_PATH, 'url']}
|
||||
>
|
||||
<Input placeholder="https://www.example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.rewriteHost')}
|
||||
name={[...MASQ_PATH, 'rewriteHost']}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.skipTlsVerify')}
|
||||
name={[...MASQ_PATH, 'insecure']}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
{m.type === 'file' && (
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.directory')}
|
||||
name={[...MASQ_PATH, 'dir']}
|
||||
>
|
||||
<Input placeholder="/var/www/html" />
|
||||
</Form.Item>
|
||||
)}
|
||||
{m.type === 'string' && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.statusCode')}
|
||||
name={[...MASQ_PATH, 'statusCode']}
|
||||
>
|
||||
<InputNumber min={0} max={599} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.body')}
|
||||
name={[...MASQ_PATH, 'content']}
|
||||
>
|
||||
<Input.TextArea autoSize={{ minRows: 3 }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.headers')}
|
||||
name={[...MASQ_PATH, 'headers']}
|
||||
>
|
||||
<HeaderMapEditor mode="v1" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
<Switch />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.skipTlsVerify')}
|
||||
name={[...MASQ, 'insecure']}
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
)}
|
||||
{masquerade.type === 'file' && (
|
||||
<FormField label={t('pages.inbounds.form.directory')} name={[...MASQ, 'dir']}>
|
||||
<Input placeholder="/var/www/html" />
|
||||
</FormField>
|
||||
)}
|
||||
{masquerade.type === 'string' && (
|
||||
<>
|
||||
<FormField label={t('pages.inbounds.form.statusCode')} name={[...MASQ, 'statusCode']}>
|
||||
<InputNumber min={0} max={599} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
<FormField label={t('pages.inbounds.form.body')} name={[...MASQ, 'content']}>
|
||||
<Input.TextArea autoSize={{ minRows: 3 }} />
|
||||
</FormField>
|
||||
<FormField label={t('pages.inbounds.form.headers')} name={[...MASQ, 'headers']}>
|
||||
<HeaderMapEditor mode="v1" />
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,40 +1,42 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, InputNumber } from 'antd';
|
||||
import { InputNumber } from 'antd';
|
||||
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
export default function KcpForm() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Form.Item label="MTU" name={['streamSettings', 'kcpSettings', 'mtu']}>
|
||||
<FormField label="MTU" name={['streamSettings', 'kcpSettings', 'mtu']}>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.inbounds.form.ttiMs')} name={['streamSettings', 'kcpSettings', 'tti']}>
|
||||
</FormField>
|
||||
<FormField label={t('pages.inbounds.form.ttiMs')} name={['streamSettings', 'kcpSettings', 'tti']}>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.uplinkMbps')}
|
||||
name={['streamSettings', 'kcpSettings', 'uplinkCapacity']}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.downlinkMbps')}
|
||||
name={['streamSettings', 'kcpSettings', 'downlinkCapacity']}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.cwndMultiplier')}
|
||||
name={['streamSettings', 'kcpSettings', 'cwndMultiplier']}
|
||||
>
|
||||
<InputNumber min={1} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.maxSendingWindow')}
|
||||
name={['streamSettings', 'kcpSettings', 'maxSendingWindow']}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,63 +1,58 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, InputNumber, Select, Switch, type FormInstance } from 'antd';
|
||||
import { InputNumber, Select, Switch } from 'antd';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
import { isMuxAllowed } from '../outbound-form-helpers';
|
||||
|
||||
interface MuxFormProps {
|
||||
form: FormInstance<OutboundFormValues>;
|
||||
protocol: string;
|
||||
network: string;
|
||||
}
|
||||
|
||||
export default function MuxForm({ form, protocol, network }: MuxFormProps) {
|
||||
export default function MuxForm({ protocol, network }: MuxFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const flow = (form.getFieldValue(['settings', 'flow']) ?? '') as string;
|
||||
const { control } = useFormContext();
|
||||
const flow = (useWatch({ control, name: 'settings.flow' }) ?? '') as string;
|
||||
const muxEnabled = !!useWatch({ control, name: 'mux.enabled' });
|
||||
if (!isMuxAllowed(protocol, flow, network)) return null;
|
||||
return (
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{() => {
|
||||
const muxEnabled = !!form.getFieldValue(['mux', 'enabled']);
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.settings.mux')}
|
||||
name={['mux', 'enabled']}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
{muxEnabled && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.settings.subFormats.concurrency')}
|
||||
name={['mux', 'concurrency']}
|
||||
>
|
||||
<InputNumber min={-1} max={1024} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.settings.subFormats.xudpConcurrency')}
|
||||
name={['mux', 'xudpConcurrency']}
|
||||
>
|
||||
<InputNumber min={-1} max={1024} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.settings.subFormats.xudpUdp443')}
|
||||
name={['mux', 'xudpProxyUDP443']}
|
||||
>
|
||||
<Select
|
||||
options={['reject', 'allow', 'skip'].map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<>
|
||||
<FormField
|
||||
label={t('pages.settings.mux')}
|
||||
name={['mux', 'enabled']}
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
{muxEnabled && (
|
||||
<>
|
||||
<FormField
|
||||
label={t('pages.settings.subFormats.concurrency')}
|
||||
name={['mux', 'concurrency']}
|
||||
>
|
||||
<InputNumber min={-1} max={1024} />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.settings.subFormats.xudpConcurrency')}
|
||||
name={['mux', 'xudpConcurrency']}
|
||||
>
|
||||
<InputNumber min={-1} max={1024} />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.settings.subFormats.xudpUdp443')}
|
||||
name={['mux', 'xudpProxyUDP443']}
|
||||
>
|
||||
<Select
|
||||
options={['reject', 'allow', 'skip'].map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,136 +1,109 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, Switch, type FormInstance } from 'antd';
|
||||
import { Form, Input, Switch } from 'antd';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { HeaderMapEditor } from '@/components/form';
|
||||
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
export default function RawForm({ form }: { form: FormInstance<OutboundFormValues> }) {
|
||||
export default function RawForm() {
|
||||
const { t } = useTranslation();
|
||||
const { control, setValue } = useFormContext();
|
||||
const type = (useWatch({
|
||||
control,
|
||||
name: 'streamSettings.tcpSettings.header.type',
|
||||
}) ?? 'none') as string;
|
||||
return (
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{() => {
|
||||
const type =
|
||||
form.getFieldValue([
|
||||
'streamSettings',
|
||||
'tcpSettings',
|
||||
'header',
|
||||
'type',
|
||||
]) ?? 'none';
|
||||
return (
|
||||
<>
|
||||
<Form.Item label={`HTTP ${t('camouflage')}`}>
|
||||
<Switch
|
||||
checked={type === 'http'}
|
||||
onChange={(checked) =>
|
||||
form.setFieldValue(
|
||||
['streamSettings', 'tcpSettings', 'header'],
|
||||
checked
|
||||
? {
|
||||
type: 'http',
|
||||
request: {
|
||||
version: '1.1',
|
||||
method: 'GET',
|
||||
path: ['/'],
|
||||
headers: {},
|
||||
},
|
||||
response: {
|
||||
version: '1.1',
|
||||
status: '200',
|
||||
reason: 'OK',
|
||||
headers: {},
|
||||
},
|
||||
}
|
||||
: { type: 'none' },
|
||||
)
|
||||
<>
|
||||
<Form.Item label={`HTTP ${t('camouflage')}`}>
|
||||
<Switch
|
||||
checked={type === 'http'}
|
||||
onChange={(checked) =>
|
||||
setValue(
|
||||
'streamSettings.tcpSettings.header',
|
||||
checked
|
||||
? {
|
||||
type: 'http',
|
||||
request: {
|
||||
version: '1.1',
|
||||
method: 'GET',
|
||||
path: ['/'],
|
||||
headers: {},
|
||||
},
|
||||
response: {
|
||||
version: '1.1',
|
||||
status: '200',
|
||||
reason: 'OK',
|
||||
headers: {},
|
||||
},
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
{type === 'http' && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.requestVersion')}
|
||||
name={[
|
||||
'streamSettings', 'tcpSettings', 'header',
|
||||
'request', 'version',
|
||||
]}
|
||||
>
|
||||
<Input placeholder="1.1" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.requestMethod')}
|
||||
name={[
|
||||
'streamSettings', 'tcpSettings', 'header',
|
||||
'request', 'method',
|
||||
]}
|
||||
>
|
||||
<Input placeholder="GET" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.requestPath')}
|
||||
name={[
|
||||
'streamSettings', 'tcpSettings', 'header',
|
||||
'request', 'path',
|
||||
]}
|
||||
getValueProps={(v) => ({ value: Array.isArray(v) ? v.join(',') : v })}
|
||||
getValueFromEvent={(e) => {
|
||||
const raw = (e?.target?.value ?? '') as string;
|
||||
const parts = raw.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
return parts.length > 0 ? parts : ['/'];
|
||||
}}
|
||||
>
|
||||
<Input placeholder="/" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.requestHeaders')}
|
||||
name={[
|
||||
'streamSettings', 'tcpSettings', 'header',
|
||||
'request', 'headers',
|
||||
]}
|
||||
>
|
||||
<HeaderMapEditor mode="v2" />
|
||||
</Form.Item>
|
||||
: { type: 'none' },
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
{type === 'http' && (
|
||||
<>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.requestVersion')}
|
||||
name={['streamSettings', 'tcpSettings', 'header', 'request', 'version']}
|
||||
>
|
||||
<Input placeholder="1.1" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.requestMethod')}
|
||||
name={['streamSettings', 'tcpSettings', 'header', 'request', 'method']}
|
||||
>
|
||||
<Input placeholder="GET" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.requestPath')}
|
||||
name={['streamSettings', 'tcpSettings', 'header', 'request', 'path']}
|
||||
transform={{
|
||||
input: (v) => (Array.isArray(v) ? v.join(',') : v),
|
||||
output: (raw) => {
|
||||
const parts = String(raw ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return parts.length > 0 ? parts : ['/'];
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Input placeholder="/" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.requestHeaders')}
|
||||
name={['streamSettings', 'tcpSettings', 'header', 'request', 'headers']}
|
||||
>
|
||||
<HeaderMapEditor mode="v2" />
|
||||
</FormField>
|
||||
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.responseVersion')}
|
||||
name={[
|
||||
'streamSettings', 'tcpSettings', 'header',
|
||||
'response', 'version',
|
||||
]}
|
||||
>
|
||||
<Input placeholder="1.1" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.responseStatus')}
|
||||
name={[
|
||||
'streamSettings', 'tcpSettings', 'header',
|
||||
'response', 'status',
|
||||
]}
|
||||
>
|
||||
<Input placeholder="200" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.responseReason')}
|
||||
name={[
|
||||
'streamSettings', 'tcpSettings', 'header',
|
||||
'response', 'reason',
|
||||
]}
|
||||
>
|
||||
<Input placeholder="OK" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.responseHeaders')}
|
||||
name={[
|
||||
'streamSettings', 'tcpSettings', 'header',
|
||||
'response', 'headers',
|
||||
]}
|
||||
>
|
||||
<HeaderMapEditor mode="v2" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.responseVersion')}
|
||||
name={['streamSettings', 'tcpSettings', 'header', 'response', 'version']}
|
||||
>
|
||||
<Input placeholder="1.1" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.responseStatus')}
|
||||
name={['streamSettings', 'tcpSettings', 'header', 'response', 'status']}
|
||||
>
|
||||
<Input placeholder="200" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.responseReason')}
|
||||
name={['streamSettings', 'tcpSettings', 'header', 'response', 'reason']}
|
||||
>
|
||||
<Input placeholder="OK" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.responseHeaders')}
|
||||
name={['streamSettings', 'tcpSettings', 'header', 'response', 'headers']}
|
||||
>
|
||||
<HeaderMapEditor mode="v2" />
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,225 +1,209 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
|
||||
import { Form, Input, InputNumber, Select, Switch } from 'antd';
|
||||
import { Controller, useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { CustomSockoptList } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { SockoptCustomField } from '@/lib/xray/forms/fields';
|
||||
import { DOMAIN_STRATEGY_OPTION, TCP_CONGESTION_OPTION } from '@/schemas/primitives';
|
||||
import { HappyEyeballsSchema, SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
|
||||
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
|
||||
|
||||
import { ADDRESS_PORT_STRATEGY_OPTIONS } from '../outbound-form-constants';
|
||||
|
||||
export default function SockoptForm({
|
||||
form,
|
||||
outboundTags = [],
|
||||
}: {
|
||||
form: FormInstance<OutboundFormValues>;
|
||||
outboundTags?: string[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { control, setValue } = useFormContext();
|
||||
const sockopt = useWatch({ control, name: 'streamSettings.sockopt' });
|
||||
const hasSockopt = !!sockopt;
|
||||
const dialerProxy = (useWatch({ control, name: 'streamSettings.sockopt.dialerProxy' }) ?? '') as string;
|
||||
const happyEyeballs = useWatch({ control, name: 'streamSettings.sockopt.happyEyeballs' });
|
||||
const hasHe = happyEyeballs != null;
|
||||
const dialerProxyOptions = Array.from(
|
||||
new Set([...outboundTags, dialerProxy].filter(Boolean)),
|
||||
).map((tg) => ({ value: tg, label: tg }));
|
||||
return (
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{() => {
|
||||
const hasSockopt = !!form.getFieldValue([
|
||||
'streamSettings',
|
||||
'sockopt',
|
||||
]);
|
||||
const dialerProxy = (form.getFieldValue([
|
||||
'streamSettings',
|
||||
'sockopt',
|
||||
'dialerProxy',
|
||||
]) ?? '') as string;
|
||||
const dialerProxyOptions = Array.from(
|
||||
new Set([...outboundTags, dialerProxy].filter(Boolean)),
|
||||
).map((tg) => ({ value: tg, label: tg }));
|
||||
return (
|
||||
<>
|
||||
<Form.Item label={t('pages.xray.outboundForm.sockopts')}>
|
||||
<Switch
|
||||
checked={hasSockopt}
|
||||
onChange={(checked) => {
|
||||
form.setFieldValue(
|
||||
['streamSettings', 'sockopt'],
|
||||
checked ? SockoptStreamSettingsSchema.parse({}) : undefined,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{hasSockopt && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.dialerProxy')}
|
||||
name={['streamSettings', 'sockopt', 'dialerProxy']}
|
||||
tooltip={t('pages.xray.outboundForm.dialerProxyHint')}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder={t('pages.xray.outboundForm.dialerProxyPlaceholder')}
|
||||
options={dialerProxyOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.xray.wireguard.domainStrategy')}
|
||||
name={['streamSettings', 'sockopt', 'domainStrategy']}
|
||||
>
|
||||
<Select
|
||||
options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.addressPortStrategy')}
|
||||
name={['streamSettings', 'sockopt', 'addressPortStrategy']}
|
||||
>
|
||||
<Select options={ADDRESS_PORT_STRATEGY_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.xray.outboundForm.keepAliveInterval')}
|
||||
name={['streamSettings', 'sockopt', 'tcpKeepAliveInterval']}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.tcpFastOpen')}
|
||||
name={['streamSettings', 'sockopt', 'tcpFastOpen']}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.multipathTcp')}
|
||||
name={['streamSettings', 'sockopt', 'tcpMptcp']}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.penetrate')}
|
||||
name={['streamSettings', 'sockopt', 'penetrate']}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.xray.outboundForm.markFwmark')}
|
||||
name={['streamSettings', 'sockopt', 'mark']}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.xray.outboundForm.interface')}
|
||||
name={['streamSettings', 'sockopt', 'interface']}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="TProxy"
|
||||
name={['streamSettings', 'sockopt', 'tproxy']}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'off', label: 'off' },
|
||||
{ value: 'redirect', label: 'redirect' },
|
||||
{ value: 'tproxy', label: 'tproxy' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.tcpCongestion')}
|
||||
name={['streamSettings', 'sockopt', 'tcpcongestion']}
|
||||
>
|
||||
<Select
|
||||
options={Object.values(TCP_CONGESTION_OPTION).map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.xray.outboundForm.tcpUserTimeoutMs')}
|
||||
name={['streamSettings', 'sockopt', 'tcpUserTimeout']}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.xray.outboundForm.tcpKeepAliveIdleS')}
|
||||
name={['streamSettings', 'sockopt', 'tcpKeepAliveIdle']}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.tcpMaxSeg')}
|
||||
name={['streamSettings', 'sockopt', 'tcpMaxSeg']}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.tcpWindowClamp')}
|
||||
name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
|
||||
tooltip={t('pages.inbounds.form.tcpWindowClampHint')}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{() => {
|
||||
const he = form.getFieldValue([
|
||||
'streamSettings', 'sockopt', 'happyEyeballs',
|
||||
]);
|
||||
const hasHe = he != null;
|
||||
return (
|
||||
<>
|
||||
<Form.Item label="Happy Eyeballs">
|
||||
<Switch
|
||||
checked={hasHe}
|
||||
onChange={(v) => {
|
||||
form.setFieldValue(
|
||||
['streamSettings', 'sockopt', 'happyEyeballs'],
|
||||
v ? HappyEyeballsSchema.parse({}) : undefined,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{hasHe && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.tryDelayMs')}
|
||||
name={['streamSettings', 'sockopt', 'happyEyeballs', 'tryDelayMs']}
|
||||
>
|
||||
<InputNumber min={0} placeholder="0 (disabled) — 250 recommended" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.prioritizeIPv6')}
|
||||
name={['streamSettings', 'sockopt', 'happyEyeballs', 'prioritizeIPv6']}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.interleave')}
|
||||
name={['streamSettings', 'sockopt', 'happyEyeballs', 'interleave']}
|
||||
>
|
||||
<InputNumber min={1} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.maxConcurrentTry')}
|
||||
name={['streamSettings', 'sockopt', 'happyEyeballs', 'maxConcurrentTry']}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<CustomSockoptList />
|
||||
</>
|
||||
<>
|
||||
<Form.Item label={t('pages.xray.outboundForm.sockopts')}>
|
||||
<Switch
|
||||
checked={hasSockopt}
|
||||
onChange={(checked) => {
|
||||
setValue(
|
||||
'streamSettings.sockopt',
|
||||
checked ? SockoptStreamSettingsSchema.parse({}) : undefined,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{hasSockopt && (
|
||||
<>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.dialerProxy')}
|
||||
name={['streamSettings', 'sockopt', 'dialerProxy']}
|
||||
tooltip={t('pages.xray.outboundForm.dialerProxyHint')}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder={t('pages.xray.outboundForm.dialerProxyPlaceholder')}
|
||||
options={dialerProxyOptions}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.xray.wireguard.domainStrategy')}
|
||||
name={['streamSettings', 'sockopt', 'domainStrategy']}
|
||||
>
|
||||
<Select
|
||||
options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.addressPortStrategy')}
|
||||
name={['streamSettings', 'sockopt', 'addressPortStrategy']}
|
||||
>
|
||||
<Select options={ADDRESS_PORT_STRATEGY_OPTIONS} />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.keepAliveInterval')}
|
||||
name={['streamSettings', 'sockopt', 'tcpKeepAliveInterval']}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.tcpFastOpen')}
|
||||
name={['streamSettings', 'sockopt', 'tcpFastOpen']}
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.multipathTcp')}
|
||||
name={['streamSettings', 'sockopt', 'tcpMptcp']}
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.penetrate')}
|
||||
name={['streamSettings', 'sockopt', 'penetrate']}
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.markFwmark')}
|
||||
name={['streamSettings', 'sockopt', 'mark']}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.interface')}
|
||||
name={['streamSettings', 'sockopt', 'interface']}
|
||||
>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField
|
||||
label="TProxy"
|
||||
name={['streamSettings', 'sockopt', 'tproxy']}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'off', label: 'off' },
|
||||
{ value: 'redirect', label: 'redirect' },
|
||||
{ value: 'tproxy', label: 'tproxy' },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.tcpCongestion')}
|
||||
name={['streamSettings', 'sockopt', 'tcpcongestion']}
|
||||
>
|
||||
<Select
|
||||
options={Object.values(TCP_CONGESTION_OPTION).map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.tcpUserTimeoutMs')}
|
||||
name={['streamSettings', 'sockopt', 'tcpUserTimeout']}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.tcpKeepAliveIdleS')}
|
||||
name={['streamSettings', 'sockopt', 'tcpKeepAliveIdle']}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.tcpMaxSeg')}
|
||||
name={['streamSettings', 'sockopt', 'tcpMaxSeg']}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.tcpWindowClamp')}
|
||||
name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
|
||||
tooltip={t('pages.inbounds.form.tcpWindowClampHint')}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</FormField>
|
||||
<Form.Item label="Happy Eyeballs">
|
||||
<Switch
|
||||
checked={hasHe}
|
||||
onChange={(v) => {
|
||||
setValue(
|
||||
'streamSettings.sockopt.happyEyeballs',
|
||||
v ? HappyEyeballsSchema.parse({}) : undefined,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{hasHe && (
|
||||
<>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.tryDelayMs')}
|
||||
name={['streamSettings', 'sockopt', 'happyEyeballs', 'tryDelayMs']}
|
||||
>
|
||||
<InputNumber min={0} placeholder="0 (disabled) — 250 recommended" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.prioritizeIPv6')}
|
||||
name={['streamSettings', 'sockopt', 'happyEyeballs', 'prioritizeIPv6']}
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.interleave')}
|
||||
name={['streamSettings', 'sockopt', 'happyEyeballs', 'interleave']}
|
||||
>
|
||||
<InputNumber min={1} />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.maxConcurrentTry')}
|
||||
name={['streamSettings', 'sockopt', 'happyEyeballs', 'maxConcurrentTry']}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="streamSettings.sockopt.customSockopt"
|
||||
render={({ field }) => (
|
||||
<SockoptCustomField value={field.value} onChange={field.onChange} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,31 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, InputNumber } from 'antd';
|
||||
import { Input, InputNumber } from 'antd';
|
||||
|
||||
import { HeaderMapEditor } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
export default function WsForm() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Form.Item label={t('host')} name={['streamSettings', 'wsSettings', 'host']}>
|
||||
<FormField label={t('host')} name={['streamSettings', 'wsSettings', 'host']}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('path')} name={['streamSettings', 'wsSettings', 'path']}>
|
||||
</FormField>
|
||||
<FormField label={t('path')} name={['streamSettings', 'wsSettings', 'path']}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.heartbeatPeriod')}
|
||||
name={['streamSettings', 'wsSettings', 'heartbeatPeriod']}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.headers')}
|
||||
name={['streamSettings', 'wsSettings', 'headers']}
|
||||
>
|
||||
<HeaderMapEditor mode="v1" />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,151 +1,141 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AutoComplete, Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
|
||||
import { AutoComplete, Input, InputNumber, Select, Switch } from 'antd';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { HeaderMapEditor } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { validateSessionIDLength, validateSessionIDTable } from '@/lib/xray/xhttp-session-id';
|
||||
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
|
||||
import { XHTTP_SESSION_ID_TABLES } from '@/schemas/protocols/stream/xhttp';
|
||||
|
||||
import { MODE_OPTIONS } from '../outbound-form-constants';
|
||||
|
||||
interface XhttpFormProps {
|
||||
form: FormInstance<OutboundFormValues>;
|
||||
onXmuxToggle: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export default function XhttpForm({ form, onXmuxToggle }: XhttpFormProps) {
|
||||
function antdValidatorToRhf(fn: (rule: unknown, value: unknown) => Promise<void>) {
|
||||
return async (value: unknown): Promise<true | string> => {
|
||||
try {
|
||||
await fn(undefined, value);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return (e as Error).message;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const XH = 'streamSettings.xhttpSettings';
|
||||
|
||||
export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext();
|
||||
const mode = useWatch({ control, name: `${XH}.mode` }) as string | undefined;
|
||||
const obfs = !!useWatch({ control, name: `${XH}.xPaddingObfsMode` });
|
||||
const sessionPlacement = useWatch({ control, name: `${XH}.sessionIDPlacement` }) as string | undefined;
|
||||
const table = useWatch({ control, name: `${XH}.sessionIDTable` });
|
||||
const seqPlacement = useWatch({ control, name: `${XH}.seqPlacement` }) as string | undefined;
|
||||
const uplinkDataPlacement = useWatch({ control, name: `${XH}.uplinkDataPlacement` }) as string | undefined;
|
||||
const enableXmux = !!useWatch({ control, name: `${XH}.enableXmux` });
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('host')}
|
||||
name={['streamSettings', 'xhttpSettings', 'host']}
|
||||
>
|
||||
<FormField label={t('host')} name={['streamSettings', 'xhttpSettings', 'host']}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('path')}
|
||||
name={['streamSettings', 'xhttpSettings', 'path']}
|
||||
>
|
||||
</FormField>
|
||||
<FormField label={t('path')} name={['streamSettings', 'xhttpSettings', 'path']}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.info.mode')}
|
||||
name={['streamSettings', 'xhttpSettings', 'mode']}
|
||||
>
|
||||
</FormField>
|
||||
<FormField label={t('pages.inbounds.info.mode')} name={['streamSettings', 'xhttpSettings', 'mode']}>
|
||||
<Select options={MODE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.paddingBytes')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xPaddingBytes']}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.headers')}
|
||||
name={['streamSettings', 'xhttpSettings', 'headers']}
|
||||
>
|
||||
<HeaderMapEditor mode="v1" />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
|
||||
{/* Padding obfs sub-section: gated by a Switch.
|
||||
When on, four extra knobs (key/header/placement/
|
||||
method) tune how Xray injects random padding to
|
||||
disguise the post body shape. */}
|
||||
<Form.Item
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.paddingObfsMode')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xPaddingObfsMode']}
|
||||
valuePropName="checked"
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{() => {
|
||||
const obfs = !!form.getFieldValue([
|
||||
'streamSettings', 'xhttpSettings', 'xPaddingObfsMode',
|
||||
]);
|
||||
if (!obfs) return null;
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.paddingKey')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xPaddingKey']}
|
||||
>
|
||||
<Input placeholder="x_padding" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.paddingHeader')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xPaddingHeader']}
|
||||
>
|
||||
<Input placeholder="X-Padding" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.paddingPlacement')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xPaddingPlacement']}
|
||||
>
|
||||
<Select
|
||||
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={t('pages.inbounds.form.paddingMethod')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xPaddingMethod']}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'Default (repeat-x)' },
|
||||
{ value: 'repeat-x', label: 'repeat-x' },
|
||||
{ value: 'tokenish', label: 'tokenish' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
{obfs && (
|
||||
<>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.paddingKey')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xPaddingKey']}
|
||||
>
|
||||
<Input placeholder="x_padding" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.paddingHeader')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xPaddingHeader']}
|
||||
>
|
||||
<Input placeholder="X-Padding" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.paddingPlacement')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xPaddingPlacement']}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'Default (queryInHeader)' },
|
||||
{ value: 'queryInHeader', label: 'queryInHeader' },
|
||||
{ value: 'header', label: 'header' },
|
||||
{ value: 'cookie', label: 'cookie' },
|
||||
{ value: 'query', label: 'query' },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.paddingMethod')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xPaddingMethod']}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'Default (repeat-x)' },
|
||||
{ value: 'repeat-x', label: 'repeat-x' },
|
||||
{ value: 'tokenish', label: 'tokenish' },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) =>
|
||||
prev?.streamSettings?.xhttpSettings?.mode !==
|
||||
curr?.streamSettings?.xhttpSettings?.mode
|
||||
}
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.uplinkHttpMethod')}
|
||||
name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']}
|
||||
>
|
||||
{() => {
|
||||
const mode = form.getFieldValue([
|
||||
'streamSettings', 'xhttpSettings', 'mode',
|
||||
]);
|
||||
return (
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.uplinkHttpMethod')}
|
||||
name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']}
|
||||
>
|
||||
<Select
|
||||
placeholder="Default (POST)"
|
||||
options={[
|
||||
{ value: '', label: 'Default (POST)' },
|
||||
{ value: 'POST', label: 'POST' },
|
||||
{ value: 'PUT', label: 'PUT' },
|
||||
{ value: 'GET', label: 'GET (packet-up only)', disabled: mode !== 'packet-up' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Select
|
||||
placeholder="Default (POST)"
|
||||
options={[
|
||||
{ value: '', label: 'Default (POST)' },
|
||||
{ value: 'POST', label: 'POST' },
|
||||
{ value: 'PUT', label: 'PUT' },
|
||||
{ value: 'GET', label: 'GET (packet-up only)', disabled: mode !== 'packet-up' },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Session + sequence + uplinkData placements:
|
||||
three orthogonal slots Xray uses to thread
|
||||
request metadata through the transport
|
||||
(path / header / cookie / query). Key field
|
||||
only matters when placement is not 'path'. */}
|
||||
<Form.Item
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.sessionPlacement')}
|
||||
name={['streamSettings', 'xhttpSettings', 'sessionIDPlacement']}
|
||||
>
|
||||
@@ -159,54 +149,38 @@ export default function XhttpForm({ form, onXmuxToggle }: XhttpFormProps) {
|
||||
{ value: 'query', label: 'query' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{() => {
|
||||
const placement = form.getFieldValue([
|
||||
'streamSettings', 'xhttpSettings', 'sessionIDPlacement',
|
||||
]);
|
||||
if (!placement || placement === 'path') return null;
|
||||
return (
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.sessionKey')}
|
||||
name={['streamSettings', 'xhttpSettings', 'sessionIDKey']}
|
||||
>
|
||||
<Input placeholder="x_session" />
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
{sessionPlacement && sessionPlacement !== 'path' && (
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.sessionKey')}
|
||||
name={['streamSettings', 'xhttpSettings', 'sessionIDKey']}
|
||||
>
|
||||
<Input placeholder="x_session" />
|
||||
</FormField>
|
||||
)}
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.sessionIDTable')}
|
||||
tooltip={t('pages.inbounds.form.sessionIDTableHint')}
|
||||
name={['streamSettings', 'xhttpSettings', 'sessionIDTable']}
|
||||
rules={[{ validator: validateSessionIDTable }]}
|
||||
rules={{ validate: antdValidatorToRhf(validateSessionIDTable) }}
|
||||
>
|
||||
<AutoComplete
|
||||
allowClear
|
||||
options={XHTTP_SESSION_ID_TABLES.map((v) => ({ value: v }))}
|
||||
placeholder="Base62"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{() => {
|
||||
const table = form.getFieldValue([
|
||||
'streamSettings', 'xhttpSettings', 'sessionIDTable',
|
||||
]);
|
||||
if (!table) return null;
|
||||
return (
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.sessionIDLength')}
|
||||
tooltip={t('pages.inbounds.form.sessionIDLengthHint')}
|
||||
name={['streamSettings', 'xhttpSettings', 'sessionIDLength']}
|
||||
rules={[{ validator: validateSessionIDLength }]}
|
||||
>
|
||||
<Input placeholder="8-16" />
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
</FormField>
|
||||
{!!table && (
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.sessionIDLength')}
|
||||
tooltip={t('pages.inbounds.form.sessionIDLengthHint')}
|
||||
name={['streamSettings', 'xhttpSettings', 'sessionIDLength']}
|
||||
rules={{ validate: antdValidatorToRhf(validateSessionIDLength) }}
|
||||
>
|
||||
<Input placeholder="8-16" />
|
||||
</FormField>
|
||||
)}
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.sequencePlacement')}
|
||||
name={['streamSettings', 'xhttpSettings', 'seqPlacement']}
|
||||
>
|
||||
@@ -220,168 +194,130 @@ export default function XhttpForm({ form, onXmuxToggle }: XhttpFormProps) {
|
||||
{ value: 'query', label: 'query' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{() => {
|
||||
const placement = form.getFieldValue([
|
||||
'streamSettings', 'xhttpSettings', 'seqPlacement',
|
||||
]);
|
||||
if (!placement || placement === 'path') return null;
|
||||
return (
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.sequenceKey')}
|
||||
name={['streamSettings', 'xhttpSettings', 'seqKey']}
|
||||
>
|
||||
<Input placeholder="x_seq" />
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
{seqPlacement && seqPlacement !== 'path' && (
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.sequenceKey')}
|
||||
name={['streamSettings', 'xhttpSettings', 'seqKey']}
|
||||
>
|
||||
<Input placeholder="x_seq" />
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{/* Mode-conditional sub-sections. */}
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{() => {
|
||||
const mode = form.getFieldValue([
|
||||
'streamSettings', 'xhttpSettings', 'mode',
|
||||
]);
|
||||
if (mode !== 'packet-up' && mode !== 'auto') return null;
|
||||
return (
|
||||
{(mode === 'packet-up' || mode === 'auto') && (
|
||||
<>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.minUploadInterval')}
|
||||
name={['streamSettings', 'xhttpSettings', 'scMinPostsIntervalMs']}
|
||||
>
|
||||
<Input placeholder="e.g. 50-150" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.maxUploadSizeBytes')}
|
||||
name={['streamSettings', 'xhttpSettings', 'scMaxEachPostBytes']}
|
||||
>
|
||||
<Input placeholder="1000000" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.uplinkDataPlacement')}
|
||||
name={['streamSettings', 'xhttpSettings', 'uplinkDataPlacement']}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'Default (body)' },
|
||||
{ value: 'body', label: 'body' },
|
||||
{ value: 'header', label: 'header' },
|
||||
{ value: 'cookie', label: 'cookie' },
|
||||
{ value: 'query', label: 'query' },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
{uplinkDataPlacement && uplinkDataPlacement !== 'body' && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.xray.outboundForm.minUploadInterval')}
|
||||
name={['streamSettings', 'xhttpSettings', 'scMinPostsIntervalMs']}
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.uplinkDataKey')}
|
||||
name={['streamSettings', 'xhttpSettings', 'uplinkDataKey']}
|
||||
>
|
||||
<Input placeholder="e.g. 50-150" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.xray.outboundForm.maxUploadSizeBytes')}
|
||||
name={['streamSettings', 'xhttpSettings', 'scMaxEachPostBytes']}
|
||||
<Input placeholder="x_data" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.uplinkChunkSize')}
|
||||
name={['streamSettings', 'xhttpSettings', 'uplinkChunkSize']}
|
||||
>
|
||||
<Input placeholder="1000000" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.uplinkDataPlacement')}
|
||||
name={['streamSettings', 'xhttpSettings', 'uplinkDataPlacement']}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'Default (body)' },
|
||||
{ value: 'body', label: 'body' },
|
||||
{ value: 'header', label: 'header' },
|
||||
{ value: 'cookie', label: 'cookie' },
|
||||
{ value: 'query', label: 'query' },
|
||||
]}
|
||||
<InputNumber
|
||||
min={0}
|
||||
placeholder="0 (unlimited)"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{() => {
|
||||
const place = form.getFieldValue([
|
||||
'streamSettings', 'xhttpSettings', 'uplinkDataPlacement',
|
||||
]);
|
||||
if (!place || place === 'body') return null;
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.uplinkDataKey')}
|
||||
name={['streamSettings', 'xhttpSettings', 'uplinkDataKey']}
|
||||
>
|
||||
<Input placeholder="x_data" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.xray.outboundForm.uplinkChunkSize')}
|
||||
name={['streamSettings', 'xhttpSettings', 'uplinkChunkSize']}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
placeholder="0 (unlimited)"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{() => {
|
||||
const mode = form.getFieldValue([
|
||||
'streamSettings', 'xhttpSettings', 'mode',
|
||||
]);
|
||||
if (mode !== 'stream-up' && mode !== 'stream-one') return null;
|
||||
return (
|
||||
<Form.Item
|
||||
label={t('pages.xray.outboundForm.noGrpcHeader')}
|
||||
name={['streamSettings', 'xhttpSettings', 'noGRPCHeader']}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{(mode === 'stream-up' || mode === 'stream-one') && (
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.noGrpcHeader')}
|
||||
name={['streamSettings', 'xhttpSettings', 'noGRPCHeader']}
|
||||
valueProp="checked"
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{/* XMUX is the connection-multiplexing layer
|
||||
xHTTP uses to fan out parallel requests over
|
||||
a small pool of upstream connections. UI-only
|
||||
toggle (enableXmux) hides the 6 nested knobs
|
||||
when off. */}
|
||||
<Form.Item
|
||||
<FormField
|
||||
label="XMUX"
|
||||
name={['streamSettings', 'xhttpSettings', 'enableXmux']}
|
||||
valuePropName="checked"
|
||||
valueProp="checked"
|
||||
onAfterChange={(v) => onXmuxToggle(v as boolean)}
|
||||
>
|
||||
<Switch onChange={onXmuxToggle} />
|
||||
</Form.Item>
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{() => {
|
||||
if (!form.getFieldValue([
|
||||
'streamSettings', 'xhttpSettings', 'enableXmux',
|
||||
])) return null;
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.xray.outboundForm.maxConcurrency')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
|
||||
>
|
||||
<Input placeholder="16-32" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.xray.outboundForm.maxConnections')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
|
||||
>
|
||||
<Input placeholder="0" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.xray.outboundForm.maxReuseTimes')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xmux', 'cMaxReuseTimes']}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.xray.outboundForm.maxRequestTimes')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxRequestTimes']}
|
||||
>
|
||||
<Input placeholder="600-900" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.xray.outboundForm.maxReusableSecs')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxReusableSecs']}
|
||||
>
|
||||
<Input placeholder="1800-3000" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('pages.xray.outboundForm.keepAlivePeriod')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xmux', 'hKeepAlivePeriod']}
|
||||
>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Switch />
|
||||
</FormField>
|
||||
{enableXmux && (
|
||||
<>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.maxConcurrency')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
|
||||
>
|
||||
<Input placeholder="16-32" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.maxConnections')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
|
||||
>
|
||||
<Input placeholder="0" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.maxReuseTimes')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xmux', 'cMaxReuseTimes']}
|
||||
>
|
||||
<Input />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.maxRequestTimes')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxRequestTimes']}
|
||||
>
|
||||
<Input placeholder="600-900" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.maxReusableSecs')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxReusableSecs']}
|
||||
>
|
||||
<Input placeholder="1800-3000" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.keepAlivePeriod')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xmux', 'hKeepAlivePeriod']}
|
||||
>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Divider, Form, Input, message, Modal, Select, Tabs, Tag } from 'antd';
|
||||
import { LoginOutlined, SaveOutlined } from '@ant-design/icons';
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import './NordModal.css';
|
||||
|
||||
interface NordModalProps {
|
||||
@@ -44,6 +46,22 @@ interface NordServer {
|
||||
cityName?: string;
|
||||
}
|
||||
|
||||
interface NordFormValues {
|
||||
token: string;
|
||||
manualKey: string;
|
||||
countryId: number | null;
|
||||
cityId: number | null;
|
||||
serverId: number | null;
|
||||
}
|
||||
|
||||
const EMPTY: NordFormValues = {
|
||||
token: '',
|
||||
manualKey: '',
|
||||
countryId: null,
|
||||
cityId: null,
|
||||
serverId: null,
|
||||
};
|
||||
|
||||
function loadColor(load: number): string {
|
||||
if (load < 30) return 'green';
|
||||
if (load < 70) return 'orange';
|
||||
@@ -63,14 +81,12 @@ export default function NordModal({
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [nordData, setNordData] = useState<NordData | null>(null);
|
||||
const [token, setToken] = useState('');
|
||||
const [manualKey, setManualKey] = useState('');
|
||||
const [countries, setCountries] = useState<Country[]>([]);
|
||||
const [cities, setCities] = useState<City[]>([]);
|
||||
const [servers, setServers] = useState<NordServer[]>([]);
|
||||
const [countryId, setCountryId] = useState<number | null>(null);
|
||||
const [cityId, setCityId] = useState<number | null>(null);
|
||||
const [serverId, setServerId] = useState<number | null>(null);
|
||||
const methods = useForm<NordFormValues>({ defaultValues: EMPTY });
|
||||
const cityId = useWatch({ control: methods.control, name: 'cityId' });
|
||||
const serverId = useWatch({ control: methods.control, name: 'serverId' });
|
||||
|
||||
const nordOutboundIndex = useMemo(() => {
|
||||
const list = templateSettings?.outbounds;
|
||||
@@ -84,8 +100,8 @@ export default function NordModal({
|
||||
}, [cityId, servers]);
|
||||
|
||||
useEffect(() => {
|
||||
setServerId(filteredServers.length > 0 ? filteredServers[0].id : null);
|
||||
}, [filteredServers]);
|
||||
methods.setValue('serverId', filteredServers.length > 0 ? filteredServers[0].id : null);
|
||||
}, [filteredServers, methods]);
|
||||
|
||||
const fetchCountries = useCallback(async () => {
|
||||
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/countries');
|
||||
@@ -113,7 +129,7 @@ export default function NordModal({
|
||||
async function login() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/reg', { token });
|
||||
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/reg', { token: methods.getValues('token') });
|
||||
if (msg?.success && msg.obj) {
|
||||
setNordData(JSON.parse(msg.obj));
|
||||
await fetchCountries();
|
||||
@@ -126,7 +142,7 @@ export default function NordModal({
|
||||
async function saveKey() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/setKey', { key: manualKey });
|
||||
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/setKey', { key: methods.getValues('manualKey') });
|
||||
if (msg?.success && msg.obj) {
|
||||
setNordData(JSON.parse(msg.obj));
|
||||
await fetchCountries();
|
||||
@@ -144,14 +160,10 @@ export default function NordModal({
|
||||
onRemoveOutbound(nordOutboundIndex);
|
||||
onRemoveRoutingRules({ prefix: 'nord-' });
|
||||
setNordData(null);
|
||||
setToken('');
|
||||
setManualKey('');
|
||||
methods.reset(EMPTY);
|
||||
setCountries([]);
|
||||
setCities([]);
|
||||
setServers([]);
|
||||
setCountryId(null);
|
||||
setCityId(null);
|
||||
setServerId(null);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -159,12 +171,11 @@ export default function NordModal({
|
||||
}
|
||||
|
||||
async function fetchServers(newCountryId: number) {
|
||||
setCountryId(newCountryId);
|
||||
setLoading(true);
|
||||
setServers([]);
|
||||
setCities([]);
|
||||
setServerId(null);
|
||||
setCityId(null);
|
||||
methods.setValue('serverId', null);
|
||||
methods.setValue('cityId', null);
|
||||
try {
|
||||
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/servers', { countryId: newCountryId });
|
||||
if (!msg?.success || !msg.obj) return;
|
||||
@@ -194,7 +205,8 @@ export default function NordModal({
|
||||
}
|
||||
|
||||
function buildNordOutbound(): Record<string, unknown> | null {
|
||||
const server = servers.find((s) => s.id === serverId);
|
||||
const selectedServerId = methods.getValues('serverId');
|
||||
const server = servers.find((s) => s.id === selectedServerId);
|
||||
if (!server) return null;
|
||||
const tech = server.technologies?.find((tt) => tt.id === 35);
|
||||
const publicKey = tech?.metadata?.find((m) => m.name === 'public_key')?.value;
|
||||
@@ -244,6 +256,7 @@ export default function NordModal({
|
||||
<>
|
||||
{messageContextHolder}
|
||||
<Modal open={open} title="NordVPN NordLynx" footer={null} onCancel={onClose}>
|
||||
<FormProvider {...methods}>
|
||||
{nordData == null ? (
|
||||
<Tabs
|
||||
defaultActiveKey="token"
|
||||
@@ -258,16 +271,12 @@ export default function NordModal({
|
||||
wrapperCol={{ md: { span: 18 } }}
|
||||
className="mt-20"
|
||||
>
|
||||
<Form.Item label={t('pages.xray.nord.accessToken')}>
|
||||
<Input
|
||||
value={token}
|
||||
placeholder={t('pages.xray.nord.accessToken')}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
/>
|
||||
<Button type="primary" className="mt-10" loading={loading} icon={<LoginOutlined />} onClick={login}>
|
||||
{t('login')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<FormField name="token" label={t('pages.xray.nord.accessToken')}>
|
||||
<Input placeholder={t('pages.xray.nord.accessToken')} />
|
||||
</FormField>
|
||||
<Button type="primary" className="mt-10" loading={loading} icon={<LoginOutlined />} onClick={login}>
|
||||
{t('login')}
|
||||
</Button>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
@@ -281,16 +290,12 @@ export default function NordModal({
|
||||
wrapperCol={{ md: { span: 18 } }}
|
||||
className="mt-20"
|
||||
>
|
||||
<Form.Item label={t('pages.xray.nord.privateKey')}>
|
||||
<Input
|
||||
value={manualKey}
|
||||
placeholder={t('pages.xray.nord.privateKey')}
|
||||
onChange={(e) => setManualKey(e.target.value)}
|
||||
/>
|
||||
<Button type="primary" className="mt-10" loading={loading} icon={<SaveOutlined />} onClick={saveKey}>
|
||||
{t('save')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<FormField name="manualKey" label={t('pages.xray.nord.privateKey')}>
|
||||
<Input placeholder={t('pages.xray.nord.privateKey')} />
|
||||
</FormField>
|
||||
<Button type="primary" className="mt-10" loading={loading} icon={<SaveOutlined />} onClick={saveKey}>
|
||||
{t('save')}
|
||||
</Button>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
@@ -320,35 +325,34 @@ export default function NordModal({
|
||||
<Divider className="zero-margin">{t('pages.xray.warp.settings')}</Divider>
|
||||
|
||||
<Form colon={false} labelCol={{ md: { span: 6 } }} wrapperCol={{ md: { span: 18 } }} className="mt-10">
|
||||
<Form.Item label={t('pages.xray.outbound.country')}>
|
||||
<FormField
|
||||
name="countryId"
|
||||
label={t('pages.xray.outbound.country')}
|
||||
transform={{ input: (v) => v ?? undefined }}
|
||||
onAfterChange={(v) => fetchServers(v as number)}
|
||||
>
|
||||
<Select
|
||||
value={countryId ?? undefined}
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
onChange={(v) => fetchServers(v)}
|
||||
options={countries.map((c) => ({
|
||||
value: c.id,
|
||||
label: `${c.name} (${c.code})`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
|
||||
{cities.length > 0 && (
|
||||
<Form.Item label={t('pages.xray.outbound.city')}>
|
||||
<FormField name="cityId" label={t('pages.xray.outbound.city')}>
|
||||
<Select
|
||||
value={cityId}
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
onChange={setCityId}
|
||||
options={[{ value: null, label: t('pages.xray.outbound.allCities') }, ...cities.map((c) => ({ value: c.id, label: c.name }))]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{filteredServers.length > 0 && (
|
||||
<Form.Item label={t('pages.xray.outbound.server')}>
|
||||
<FormField name="serverId" label={t('pages.xray.outbound.server')}>
|
||||
<Select
|
||||
value={serverId}
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
onChange={setServerId}
|
||||
options={filteredServers.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.cityName} ${s.name} ${s.hostname}`,
|
||||
@@ -364,7 +368,7 @@ export default function NordModal({
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
@@ -392,6 +396,7 @@ export default function NordModal({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
Tag,
|
||||
} from 'antd';
|
||||
import { ApiOutlined, SyncOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
|
||||
import { HttpUtil, SizeFormatter, ObjectUtil, Wireguard } from '@/utils';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import './WarpModal.css';
|
||||
|
||||
interface WarpModalProps {
|
||||
@@ -51,6 +53,13 @@ interface WarpConfig {
|
||||
};
|
||||
}
|
||||
|
||||
interface WarpFormValues {
|
||||
warpPlus: string;
|
||||
updateInterval: number;
|
||||
}
|
||||
|
||||
const EMPTY: WarpFormValues = { warpPlus: '', updateInterval: 0 };
|
||||
|
||||
function addressesFor(addrs: { v4?: string; v6?: string }): string[] {
|
||||
const out: string[] = [];
|
||||
if (addrs.v4) out.push(`${addrs.v4}/32`);
|
||||
@@ -79,10 +88,10 @@ export default function WarpModal({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [warpData, setWarpData] = useState<WarpData | null>(null);
|
||||
const [warpConfig, setWarpConfig] = useState<WarpConfig | null>(null);
|
||||
const [warpPlus, setWarpPlus] = useState('');
|
||||
const [updateInterval, setUpdateInterval] = useState<number>(0);
|
||||
const [licenseError, setLicenseError] = useState('');
|
||||
const [stagedOutbound, setStagedOutbound] = useState<Record<string, unknown> | null>(null);
|
||||
const methods = useForm<WarpFormValues>({ defaultValues: EMPTY });
|
||||
const warpPlusValue = useWatch({ control: methods.control, name: 'warpPlus' }) ?? '';
|
||||
|
||||
const warpOutboundIndex = useMemo(() => {
|
||||
const list = templateSettings?.outbounds;
|
||||
@@ -132,12 +141,12 @@ export default function WarpModal({
|
||||
}
|
||||
const settingMsg = await HttpUtil.post<Record<string, unknown>>('/panel/api/setting/all');
|
||||
if (settingMsg?.success && settingMsg.obj) {
|
||||
setUpdateInterval(Number(settingMsg.obj.warpUpdateInterval) || 0);
|
||||
methods.setValue('updateInterval', Number(settingMsg.obj.warpUpdateInterval) || 0);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [methods]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -202,7 +211,7 @@ export default function WarpModal({
|
||||
async function saveInterval() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/api/xray/warp/interval', { interval: updateInterval });
|
||||
const msg = await HttpUtil.post('/panel/api/xray/warp/interval', { interval: methods.getValues('updateInterval') });
|
||||
if (msg?.success) {
|
||||
messageApi.success(t('pages.setting.toasts.saveSuccess', 'Settings saved successfully'));
|
||||
}
|
||||
@@ -212,15 +221,16 @@ export default function WarpModal({
|
||||
}
|
||||
|
||||
async function updateLicense() {
|
||||
if (warpPlus.length < 26) return;
|
||||
const licenseValue = methods.getValues('warpPlus');
|
||||
if (licenseValue.length < 26) return;
|
||||
setLoading(true);
|
||||
setLicenseError('');
|
||||
try {
|
||||
const msg = await HttpUtil.post<string>('/panel/api/xray/warp/license', { license: warpPlus });
|
||||
const msg = await HttpUtil.post<string>('/panel/api/xray/warp/license', { license: licenseValue });
|
||||
if (msg?.success && msg.obj) {
|
||||
setWarpData(JSON.parse(msg.obj));
|
||||
setWarpConfig(null);
|
||||
setWarpPlus('');
|
||||
methods.setValue('warpPlus', '');
|
||||
} else {
|
||||
setLicenseError(msg?.msg || t('pages.xray.warp.licenseError'));
|
||||
}
|
||||
@@ -266,6 +276,7 @@ export default function WarpModal({
|
||||
<>
|
||||
{messageContextHolder}
|
||||
<Modal open={open} title="Cloudflare WARP" footer={null} onCancel={onClose}>
|
||||
<FormProvider {...methods}>
|
||||
{!hasWarp ? (
|
||||
<Button type="primary" loading={loading} icon={<ApiOutlined />} onClick={register}>
|
||||
{t('pages.xray.warp.createAccount')}
|
||||
@@ -307,29 +318,26 @@ export default function WarpModal({
|
||||
label: t('pages.xray.warp.licenseKeyLabel'),
|
||||
children: (
|
||||
<Form colon={false} labelCol={{ md: { span: 6 } }} wrapperCol={{ md: { span: 14 } }}>
|
||||
<Form.Item label={t('pages.xray.warp.key')}>
|
||||
<Input
|
||||
value={warpPlus}
|
||||
placeholder={t('pages.xray.warp.keyPlaceholder')}
|
||||
onChange={(e) => {
|
||||
setWarpPlus(e.target.value);
|
||||
setLicenseError('');
|
||||
}}
|
||||
/>
|
||||
<div className="license-actions mt-8">
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={warpPlus.length < 26}
|
||||
loading={loading}
|
||||
onClick={updateLicense}
|
||||
>
|
||||
{t('update')}
|
||||
</Button>
|
||||
{licenseError && (
|
||||
<Alert title={licenseError} type="error" showIcon className="license-error" />
|
||||
)}
|
||||
</div>
|
||||
</Form.Item>
|
||||
<FormField
|
||||
name="warpPlus"
|
||||
label={t('pages.xray.warp.key')}
|
||||
onAfterChange={() => setLicenseError('')}
|
||||
>
|
||||
<Input placeholder={t('pages.xray.warp.keyPlaceholder')} />
|
||||
</FormField>
|
||||
<div className="license-actions mt-8">
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={warpPlusValue.length < 26}
|
||||
loading={loading}
|
||||
onClick={updateLicense}
|
||||
>
|
||||
{t('update')}
|
||||
</Button>
|
||||
{licenseError && (
|
||||
<Alert title={licenseError} type="error" showIcon className="license-error" />
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
@@ -338,18 +346,17 @@ export default function WarpModal({
|
||||
label: t('pages.xray.warp.autoUpdateIp', 'Auto Update IP Address'),
|
||||
children: (
|
||||
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 12 } }}>
|
||||
<Form.Item label={t('pages.xray.warp.intervalDays', 'Interval (Days)')}
|
||||
tooltip={t('pages.xray.warp.intervalDesc', '0 to disable. Changes IP address automatically.')}>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={updateInterval}
|
||||
onChange={(e) => setUpdateInterval(Number(e.target.value))}
|
||||
/>
|
||||
<Button className="mt-8" type="primary" loading={loading} onClick={saveInterval}>
|
||||
{t('save', 'Save')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<FormField
|
||||
name="updateInterval"
|
||||
label={t('pages.xray.warp.intervalDays', 'Interval (Days)')}
|
||||
tooltip={t('pages.xray.warp.intervalDesc', '0 to disable. Changes IP address automatically.')}
|
||||
transform={{ output: (v) => Number(v) }}
|
||||
>
|
||||
<Input type="number" min={0} />
|
||||
</FormField>
|
||||
<Button className="mt-8" type="primary" loading={loading} onClick={saveInterval}>
|
||||
{t('save', 'Save')}
|
||||
</Button>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
@@ -431,6 +438,7 @@ export default function WarpModal({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, Modal, Select, Space, Switch, Tooltip } from 'antd';
|
||||
import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import { InputAddon } from '@/components/ui';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
||||
import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
|
||||
import { buildRemarkByTag, formatInboundTag, isApiRule } from './helpers';
|
||||
@@ -36,9 +38,7 @@ interface RuleFormModalProps {
|
||||
onConfirm: (rule: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
type FormState = RuleFormValues;
|
||||
|
||||
const initialForm = (): FormState => ({
|
||||
const initialForm = (): RuleFormValues => ({
|
||||
enabled: true,
|
||||
domain: '',
|
||||
ip: '',
|
||||
@@ -73,7 +73,7 @@ export default function RuleFormModal({
|
||||
onConfirm,
|
||||
}: RuleFormModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState<FormState>(initialForm);
|
||||
const methods = useForm<RuleFormValues>({ defaultValues: initialForm() });
|
||||
const isEdit = rule != null;
|
||||
|
||||
const { data: inboundOptions } = useInboundOptions();
|
||||
@@ -82,7 +82,7 @@ export default function RuleFormModal({
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (rule) {
|
||||
setForm({
|
||||
methods.reset({
|
||||
enabled: rule.enabled !== false,
|
||||
domain: Array.isArray(rule.domain) ? rule.domain.join(',') : rule.domain || '',
|
||||
ip: Array.isArray(rule.ip) ? rule.ip.join(',') : rule.ip || '',
|
||||
@@ -99,15 +99,14 @@ export default function RuleFormModal({
|
||||
balancerTag: rule.balancerTag || '',
|
||||
});
|
||||
} else {
|
||||
setForm(initialForm());
|
||||
methods.reset(initialForm());
|
||||
}
|
||||
}, [open, rule]);
|
||||
}, [open, rule, methods]);
|
||||
|
||||
const update = <K extends keyof FormState>(key: K, value: FormState[K]) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
const attrs = useWatch({ control: methods.control, name: 'attrs' }) ?? [];
|
||||
|
||||
function submit() {
|
||||
const validated = RuleFormSchema.safeParse(form);
|
||||
const validated = RuleFormSchema.safeParse(methods.getValues());
|
||||
if (!validated.success) return;
|
||||
const v = validated.data;
|
||||
const built: Record<string, unknown> = {
|
||||
@@ -154,167 +153,159 @@ export default function RuleFormModal({
|
||||
onOk={submit}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
|
||||
<Form.Item label={t('enable')}>
|
||||
<Switch
|
||||
checked={form.enabled}
|
||||
onChange={(checked) => update('enabled', checked)}
|
||||
disabled={isApiRule(rule ?? {})}
|
||||
/>
|
||||
</Form.Item>
|
||||
<FormProvider {...methods}>
|
||||
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
|
||||
<FormField name="enabled" label={t('enable')} valueProp="checked">
|
||||
<Switch disabled={isApiRule(rule ?? {})} />
|
||||
</FormField>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<Tooltip title={t('pages.xray.rules.useComma')}>
|
||||
{t('pages.xray.ruleForm.sourceIps')} <QuestionCircleOutlined aria-hidden="true" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Input value={form.sourceIP} onChange={(e) => update('sourceIP', e.target.value)} placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
|
||||
</Form.Item>
|
||||
<FormField
|
||||
name="sourceIP"
|
||||
label={
|
||||
<Tooltip title={t('pages.xray.rules.useComma')}>
|
||||
{t('pages.xray.ruleForm.sourceIps')} <QuestionCircleOutlined aria-hidden="true" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Input placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
|
||||
</FormField>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<Tooltip title={t('pages.xray.rules.useComma')}>
|
||||
{t('pages.xray.ruleForm.sourcePort')} <QuestionCircleOutlined aria-hidden="true" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Input value={form.sourcePort} onChange={(e) => update('sourcePort', e.target.value)} placeholder="53,443,1000-2000" />
|
||||
</Form.Item>
|
||||
<FormField
|
||||
name="sourcePort"
|
||||
label={
|
||||
<Tooltip title={t('pages.xray.rules.useComma')}>
|
||||
{t('pages.xray.ruleForm.sourcePort')} <QuestionCircleOutlined aria-hidden="true" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Input placeholder="53,443,1000-2000" />
|
||||
</FormField>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<Tooltip title={t('pages.xray.rules.useComma')}>
|
||||
{t('pages.xray.ruleForm.vlessRoute')} <QuestionCircleOutlined aria-hidden="true" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Input value={form.vlessRoute} onChange={(e) => update('vlessRoute', e.target.value)} placeholder="53,443,1000-2000" />
|
||||
</Form.Item>
|
||||
<FormField
|
||||
name="vlessRoute"
|
||||
label={
|
||||
<Tooltip title={t('pages.xray.rules.useComma')}>
|
||||
{t('pages.xray.ruleForm.vlessRoute')} <QuestionCircleOutlined aria-hidden="true" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Input placeholder="53,443,1000-2000" />
|
||||
</FormField>
|
||||
|
||||
<Form.Item label={t('pages.inbounds.network')}>
|
||||
<Select
|
||||
value={form.network}
|
||||
onChange={(v) => update('network', v)}
|
||||
options={NETWORKS.map((n) => ({ value: n, label: n || '(any)' }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<FormField name="network" label={t('pages.inbounds.network')}>
|
||||
<Select options={NETWORKS.map((n) => ({ value: n, label: n || '(any)' }))} />
|
||||
</FormField>
|
||||
|
||||
<Form.Item label={t('pages.inbounds.protocol')}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
value={form.protocol}
|
||||
onChange={(v) => update('protocol', v)}
|
||||
options={PROTOCOLS.map((p) => ({ value: p, label: p }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<FormField name="protocol" label={t('pages.inbounds.protocol')}>
|
||||
<Select mode="multiple" options={PROTOCOLS.map((p) => ({ value: p, label: p }))} />
|
||||
</FormField>
|
||||
|
||||
<Form.Item label={t('pages.xray.ruleForm.attributes')}>
|
||||
<Button size="small" aria-label={t('add')} icon={<PlusOutlined />} onClick={() => update('attrs', [...form.attrs, ['', '']])} />
|
||||
</Form.Item>
|
||||
<Form.Item wrapperCol={{ span: 24 }}>
|
||||
{form.attrs.map((attr, idx) => (
|
||||
<Space.Compact key={idx} block className="mb-8">
|
||||
<InputAddon>{`${idx + 1}`}</InputAddon>
|
||||
<Input
|
||||
value={attr[0]}
|
||||
aria-label={t('pages.nodes.name')}
|
||||
placeholder={t('pages.nodes.name')}
|
||||
onChange={(e) => {
|
||||
const next = form.attrs.map((a, i) => (i === idx ? ([e.target.value, a[1]] as [string, string]) : a));
|
||||
update('attrs', next);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
value={attr[1]}
|
||||
aria-label={t('pages.xray.ruleForm.value')}
|
||||
placeholder={t('pages.xray.ruleForm.value')}
|
||||
onChange={(e) => {
|
||||
const next = form.attrs.map((a, i) => (i === idx ? ([a[0], e.target.value] as [string, string]) : a));
|
||||
update('attrs', next);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
aria-label={t('remove')}
|
||||
icon={<MinusOutlined />}
|
||||
onClick={() => update('attrs', form.attrs.filter((_, i) => i !== idx))}
|
||||
/>
|
||||
</Space.Compact>
|
||||
))}
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.ruleForm.attributes')}>
|
||||
<Button
|
||||
size="small"
|
||||
aria-label={t('add')}
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => methods.setValue('attrs', [...attrs, ['', ''] as [string, string]])}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item wrapperCol={{ span: 24 }}>
|
||||
{attrs.map((attr, idx) => (
|
||||
<Space.Compact key={idx} block className="mb-8">
|
||||
<InputAddon>{`${idx + 1}`}</InputAddon>
|
||||
<Input
|
||||
value={attr[0]}
|
||||
aria-label={t('pages.nodes.name')}
|
||||
placeholder={t('pages.nodes.name')}
|
||||
onChange={(e) => {
|
||||
const next = attrs.map((a, i) => (i === idx ? ([e.target.value, a[1]] as [string, string]) : a));
|
||||
methods.setValue('attrs', next);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
value={attr[1]}
|
||||
aria-label={t('pages.xray.ruleForm.value')}
|
||||
placeholder={t('pages.xray.ruleForm.value')}
|
||||
onChange={(e) => {
|
||||
const next = attrs.map((a, i) => (i === idx ? ([a[0], e.target.value] as [string, string]) : a));
|
||||
methods.setValue('attrs', next);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
aria-label={t('remove')}
|
||||
icon={<MinusOutlined />}
|
||||
onClick={() => methods.setValue('attrs', attrs.filter((_, i) => i !== idx))}
|
||||
/>
|
||||
</Space.Compact>
|
||||
))}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<Tooltip title={t('pages.xray.rules.useComma')}>
|
||||
IP <QuestionCircleOutlined aria-hidden="true" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Input value={form.ip} onChange={(e) => update('ip', e.target.value)} placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
|
||||
</Form.Item>
|
||||
<FormField
|
||||
name="ip"
|
||||
label={
|
||||
<Tooltip title={t('pages.xray.rules.useComma')}>
|
||||
IP <QuestionCircleOutlined aria-hidden="true" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Input placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
|
||||
</FormField>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<Tooltip title={t('pages.xray.rules.useComma')}>
|
||||
{t('domainName')} <QuestionCircleOutlined aria-hidden="true" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Input value={form.domain} onChange={(e) => update('domain', e.target.value)} placeholder="google.com, geosite:cn" />
|
||||
</Form.Item>
|
||||
<FormField
|
||||
name="domain"
|
||||
label={
|
||||
<Tooltip title={t('pages.xray.rules.useComma')}>
|
||||
{t('domainName')} <QuestionCircleOutlined aria-hidden="true" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Input placeholder="google.com, geosite:cn" />
|
||||
</FormField>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<Tooltip title={t('pages.xray.rules.useComma')}>
|
||||
{t('pages.xray.ruleForm.user')} <QuestionCircleOutlined aria-hidden="true" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Input value={form.user} onChange={(e) => update('user', e.target.value)} placeholder="email address" />
|
||||
</Form.Item>
|
||||
<FormField
|
||||
name="user"
|
||||
label={
|
||||
<Tooltip title={t('pages.xray.rules.useComma')}>
|
||||
{t('pages.xray.ruleForm.user')} <QuestionCircleOutlined aria-hidden="true" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Input placeholder="email address" />
|
||||
</FormField>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<Tooltip title={t('pages.xray.rules.useComma')}>
|
||||
{t('pages.inbounds.port')} <QuestionCircleOutlined aria-hidden="true" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Input value={form.port} onChange={(e) => update('port', e.target.value)} placeholder="53,443,1000-2000" />
|
||||
</Form.Item>
|
||||
<FormField
|
||||
name="port"
|
||||
label={
|
||||
<Tooltip title={t('pages.xray.rules.useComma')}>
|
||||
{t('pages.inbounds.port')} <QuestionCircleOutlined aria-hidden="true" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Input placeholder="53,443,1000-2000" />
|
||||
</FormField>
|
||||
|
||||
<Form.Item label={t('pages.xray.ruleForm.inboundTags')}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
value={form.inboundTag}
|
||||
onChange={(v) => update('inboundTag', v)}
|
||||
options={inboundTags.map((tag) => ({ value: tag, label: formatInboundTag(tag, remarkByTag) }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<FormField name="inboundTag" label={t('pages.xray.ruleForm.inboundTags')}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
options={inboundTags.map((tag) => ({ value: tag, label: formatInboundTag(tag, remarkByTag) }))}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<Form.Item label={t('pages.xray.ruleForm.outboundTag')}>
|
||||
<Select
|
||||
value={form.outboundTag}
|
||||
onChange={(v) => update('outboundTag', v)}
|
||||
options={outboundTags.map((tag) => ({ value: tag, label: tag || '(none)' }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<FormField name="outboundTag" label={t('pages.xray.ruleForm.outboundTag')}>
|
||||
<Select options={outboundTags.map((tag) => ({ value: tag, label: tag || '(none)' }))} />
|
||||
</FormField>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<Tooltip title={t('pages.xray.ruleForm.balancerTagTooltip')}>
|
||||
{t('pages.xray.ruleForm.balancerTag')} <QuestionCircleOutlined aria-hidden="true" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
value={form.balancerTag}
|
||||
onChange={(v) => update('balancerTag', v)}
|
||||
options={balancerTags.map((tag) => ({ value: tag, label: tag || '(none)' }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<FormField
|
||||
name="balancerTag"
|
||||
label={
|
||||
<Tooltip title={t('pages.xray.ruleForm.balancerTagTooltip')}>
|
||||
{t('pages.xray.ruleForm.balancerTag')} <QuestionCircleOutlined aria-hidden="true" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Select options={balancerTags.map((tag) => ({ value: tag, label: tag || '(none)' }))} />
|
||||
</FormField>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user