diff --git a/frontend/src/components/HeaderMapEditor.tsx b/frontend/src/components/HeaderMapEditor.tsx index f199bbd8..c630c851 100644 --- a/frontend/src/components/HeaderMapEditor.tsx +++ b/frontend/src/components/HeaderMapEditor.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Button, Input, Space } from 'antd'; import { MinusOutlined, PlusOutlined } from '@ant-design/icons'; @@ -74,10 +74,29 @@ function rowsToMap(rows: HeaderRow[], mode: HeaderMapMode): Record mapToRows(value), [value]); + // Local state holds rows including blanks. Without it, addRow() would + // append a {name:'', value:''} that rowsToMap immediately filters out + // before reaching the form, so the new row would never reach UI. The + // form-bound map only sees rows with non-empty names; blank rows live + // here until the user fills them in. + const [rows, setRows] = useState(() => mapToRows(value)); + const lastEmittedRef = useRef(JSON.stringify(rowsToMap(rows, mode))); + + // Re-sync local rows when the form value changes from outside (modal + // re-open with edit data, JSON tab edits, etc.) but not when it's our + // own emission echoing back. + useEffect(() => { + const incoming = JSON.stringify(value ?? {}); + if (incoming === lastEmittedRef.current) return; + setRows(mapToRows(value)); + lastEmittedRef.current = incoming; + }, [value]); function commit(next: HeaderRow[]) { - onChange?.(rowsToMap(next, mode)); + setRows(next); + const map = rowsToMap(next, mode); + lastEmittedRef.current = JSON.stringify(map); + onChange?.(map); } function setRow(index: number, patch: Partial) { diff --git a/frontend/src/pages/inbounds/InboundFormModal.tsx b/frontend/src/pages/inbounds/InboundFormModal.tsx index bca358e9..60f09c60 100644 --- a/frontend/src/pages/inbounds/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/InboundFormModal.tsx @@ -63,6 +63,7 @@ import { import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt'; import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls'; import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality'; +import { SniffingSchema } from '@/schemas/primitives/sniffing'; import DateTimePicker from '@/components/DateTimePicker'; import FinalMaskForm from '@/components/FinalMaskForm'; import HeaderMapEditor from '@/components/HeaderMapEditor'; @@ -100,9 +101,26 @@ function AdvancedSliceEditor({ minHeight?: string; maxHeight?: string; }) { - const [text, setText] = useState(() => - JSON.stringify(form.getFieldValue(path) ?? {}, null, 2), - ); + // The editor keeps a local text buffer so partial / invalid JSON typing + // doesn't clobber the form. lastEmitRef tracks the serialized form value + // at the moment we last accepted a write — if useWatch later fires with + // a different value than that, the form was changed from elsewhere + // (Stream tab toggle, sibling JSON tab edit), and we re-sync. + const watched = Form.useWatch(path, form); + const lastEmitRef = useRef(''); + const [text, setText] = useState(() => { + const initial = JSON.stringify(form.getFieldValue(path) ?? {}, null, 2); + lastEmitRef.current = initial; + return initial; + }); + + useEffect(() => { + const formStr = JSON.stringify(watched ?? {}, null, 2); + if (formStr === lastEmitRef.current) return; + setText(formStr); + lastEmitRef.current = formStr; + }, [watched]); + return ( { setText(next); try { - form.setFieldValue(path, JSON.parse(next)); + const parsed = JSON.parse(next); + form.setFieldValue(path, parsed); + lastEmitRef.current = JSON.stringify(parsed, null, 2); } catch { - + // invalid JSON; keep buffer, don't push to form } }} /> ); } +// The "All" editor shows the full inbound JSON in one editor: top-level +// connection fields plus the three nested sub-objects (settings, +// streamSettings, sniffing). Edits round-trip back to the form's slices, +// mirroring the legacy modal's setAdvancedAllValue behavior. Reactivity +// works the same way as AdvancedSliceEditor: useWatch on the slices we +// care about, lastEmitRef as the "we wrote this" guard. +function AdvancedAllEditor({ + form, + streamEnabled, +}: { + form: FormInstance; + streamEnabled: boolean; +}) { + const wListen = Form.useWatch('listen', form); + const wPort = Form.useWatch('port', form); + const wProtocol = Form.useWatch('protocol', form); + const wTag = Form.useWatch('tag', form); + const wSettings = Form.useWatch('settings', form); + const wSniffing = Form.useWatch('sniffing', form); + const wStream = Form.useWatch('streamSettings', form); + + const serialize = () => { + const out: Record = { + listen: wListen ?? '', + port: wPort ?? 0, + protocol: wProtocol ?? '', + tag: wTag ?? '', + settings: wSettings ?? {}, + sniffing: wSniffing ?? {}, + }; + if (streamEnabled) out.streamSettings = wStream ?? {}; + return JSON.stringify(out, null, 2); + }; + + const lastEmitRef = useRef(''); + const [text, setText] = useState(() => { + const initial = serialize(); + lastEmitRef.current = initial; + return initial; + }); + + useEffect(() => { + const formStr = serialize(); + if (formStr === lastEmitRef.current) return; + setText(formStr); + lastEmitRef.current = formStr; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [wListen, wPort, wProtocol, wTag, wSettings, wSniffing, wStream, streamEnabled]); + + return ( + { + setText(next); + let parsed: Record; + try { + parsed = JSON.parse(next) as Record; + } catch { + return; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return; + if (typeof parsed.listen === 'string') form.setFieldValue('listen', parsed.listen); + if (typeof parsed.port === 'number' && Number.isFinite(parsed.port)) { + form.setFieldValue('port', parsed.port); + } + if (typeof parsed.protocol === 'string') form.setFieldValue('protocol', parsed.protocol); + if (typeof parsed.tag === 'string') form.setFieldValue('tag', parsed.tag); + if (parsed.settings && typeof parsed.settings === 'object') { + form.setFieldValue('settings', parsed.settings); + } + if (parsed.sniffing && typeof parsed.sniffing === 'object') { + form.setFieldValue('sniffing', parsed.sniffing); + } + if (streamEnabled && parsed.streamSettings && typeof parsed.streamSettings === 'object') { + form.setFieldValue('streamSettings', parsed.streamSettings); + } + lastEmitRef.current = next; + }} + /> + ); +} + const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p })); const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'] as const; const NODE_ELIGIBLE_PROTOCOLS = new Set([ @@ -147,7 +251,7 @@ function buildAddModeValues(): InboundFormValues { protocol: 'vless', settings, streamSettings: { network: 'tcp', security: 'none' }, - sniffing: {}, + sniffing: SniffingSchema.parse({}), port: RandomUtil.randomInteger(10000, 60000), listen: '', tag: '', @@ -186,8 +290,6 @@ export default function InboundFormModal({ const network = Form.useWatch(['streamSettings', 'network'], form) ?? ''; const security = Form.useWatch(['streamSettings', 'security'], form) ?? 'none'; const streamEnabled = canEnableStream({ protocol }); - const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } }); - const realityAllowed = canEnableReality({ protocol, streamSettings: { network, security } }); const isFallbackHost = (protocol === Protocols.VLESS || protocol === Protocols.TROJAN) && network === 'tcp' @@ -385,10 +487,6 @@ export default function InboundFormModal({ const xhttpSessionPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'sessionPlacement'], form); const xhttpSeqPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'seqPlacement'], form); const xhttpUplinkPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'uplinkDataPlacement'], form); - const externalProxyArr = Form.useWatch(['streamSettings', 'externalProxy'], form); - const externalProxyOn = Array.isArray(externalProxyArr) && externalProxyArr.length > 0; - const sockoptValue = Form.useWatch(['streamSettings', 'sockopt'], form); - const sockoptOn = !!sockoptValue && typeof sockoptValue === 'object' && Object.keys(sockoptValue as object).length > 0; const toggleExternalProxy = (on: boolean) => { if (on) { @@ -1205,9 +1303,8 @@ export default function InboundFormModal({ const streamTab = ( <> {protocol !== Protocols.HYSTERIA && ( - + - {t('pages.inbounds.same')} - {t('none')} - TLS - + {on && ( + + {(fields, { add, remove }) => ( + <> + + - - - - - - - - - - remove(field.name)}> - - - - - prev.streamSettings?.externalProxy?.[field.name]?.forceTls - !== curr.streamSettings?.externalProxy?.[field.name]?.forceTls - } - > - {({ getFieldValue }) => { - const ft = getFieldValue([ - 'streamSettings', 'externalProxy', field.name, 'forceTls', - ]); - if (ft !== 'tls') return null; - return ( - - - + + {fields.map((field) => ( +
+ + + + + + + + + + + + + + remove(field.name)}> + + + + + prev.streamSettings?.externalProxy?.[field.name]?.forceTls + !== curr.streamSettings?.externalProxy?.[field.name]?.forceTls + } + > + {({ getFieldValue }) => { + const ft = getFieldValue([ + 'streamSettings', 'externalProxy', field.name, 'forceTls', + ]); + if (ft !== 'tls') return null; + return ( + + + + + + + + + + + + ); + }} - - - - - - - - ); - }} - -
- ))} -
+ + ))} +
+ + )} +
+ )} - )} - - )} - - - + ); + }} - {sockoptOn && ( - <> + + { + const a = (prev.streamSettings as { sockopt?: object } | undefined)?.sockopt; + const b = (curr.streamSettings as { sockopt?: object } | undefined)?.sockopt; + return !!a !== !!b; + }} + > + {({ getFieldValue }) => { + const sock = getFieldValue(['streamSettings', 'sockopt']); + const on = !!sock && typeof sock === 'object' && Object.keys(sock).length > 0; + return ( + <> + + + + {on && ( + <> @@ -2021,8 +2148,12 @@ export default function InboundFormModal({ X-Client-IP - - )} + + )} + + ); + }} +
prev.streamSettings?.security !== curr.streamSettings?.security + || prev.streamSettings?.network !== curr.streamSettings?.network + || prev.protocol !== curr.protocol } > {({ getFieldValue }) => { const sec = getFieldValue(['streamSettings', 'security']) ?? 'none'; + const net = getFieldValue(['streamSettings', 'network']) ?? ''; + const proto = getFieldValue('protocol') ?? ''; + const tlsOk = canEnableTls({ protocol: proto, streamSettings: { network: net, security: sec } }); + const realityOk = canEnableReality({ protocol: proto, streamSettings: { network: net, security: sec } }); return ( - + none + tls + {realityOk && reality} + ); }}
- {security === 'tls' && ( - <> - + + prev.streamSettings?.security !== curr.streamSettings?.security + } + > + {({ getFieldValue }) => { + const sec = getFieldValue(['streamSettings', 'security']); + if (sec !== 'tls') return null; + return ( + <> + @@ -2299,11 +2445,22 @@ export default function InboundFormModal({ - - )} + + ); + }} + - {security === 'reality' && ( - <> + + prev.streamSettings?.security !== curr.streamSettings?.security + } + > + {({ getFieldValue }) => { + const sec = getFieldValue(['streamSettings', 'security']); + if (sec !== 'reality') return null; + return ( + <> Clear - - )} + + ); + }} + ); const advancedTab = ( - - ), - }, - ...(streamEnabled - ? [{ - key: 'stream', - label: t('pages.inbounds.advanced.stream'), - children: ( - - ), - }] - : []), - { - key: 'sniffing', - label: t('pages.inbounds.advanced.sniffing'), - children: ( - - ), - }, - ]} - /> +
+
+
+
+
{t('pages.inbounds.advanced.title')}
+
{t('pages.inbounds.advanced.subtitle')}
+
+
+ +
+ {t('pages.inbounds.advanced.allHelp')} +
+ + + ), + }, + { + key: 'settings', + label: t('pages.inbounds.advanced.settings'), + children: ( + <> +
+ {t('pages.inbounds.advanced.settingsHelp')}{' '} + {'{ settings: { ... } }'}. +
+ + + ), + }, + ...(streamEnabled + ? [{ + key: 'stream', + label: t('pages.inbounds.advanced.stream'), + children: ( + <> +
+ {t('pages.inbounds.advanced.streamHelp')}{' '} + {'{ streamSettings: { ... } }'}. +
+ + + ), + }] + : []), + { + key: 'sniffing', + label: t('pages.inbounds.advanced.sniffing'), + children: ( + <> +
+ {t('pages.inbounds.advanced.sniffingHelp')}{' '} + {'{ sniffing: { ... } }'}. +
+ + + ), + }, + ]} + /> +
+
); const sniffingTab = (