diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index 563f3031..79dcaf8d 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/form/InboundFormModal.tsx @@ -33,10 +33,6 @@ import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from import { rawInboundToFormValues, formValuesToWirePayload, - pruneEmpty, - normalizeSniffing, - normalizeClients, - dropLegacyOptionalEmpties, } from '@/lib/xray/inbound-form-adapter'; import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults'; import { @@ -85,10 +81,9 @@ import { FinalMaskForm } from '@/lib/xray/forms/transport'; import { HeaderMapEditor } from '@/components/form'; import { HysteriaMasqueradeForm } from '@/lib/xray/forms/protocols/shared'; import { InputAddon } from '@/components/ui'; -import { JsonEditor } from '@/components/form'; import './InboundFormModal.css'; -import type { FormInstance } from 'antd'; -import type { NamePath } from 'antd/es/form/interface'; + +import { AdvancedAllEditor, AdvancedSliceEditor } from './advanced-editors'; const { TextArea } = Input; import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound'; @@ -101,177 +96,6 @@ import type { NodeRecord } from '@/api/queries/useNodesQuery'; const { Text } = Typography; -// Sub-editor for one slice of the form (settings, streamSettings, sniffing). -// Holds a local text buffer so the user can type freely; on every keystroke -// we try to JSON.parse and forward the result to form state. Invalid JSON -// is held in the buffer until the next valid moment — no panic on partial -// input. The buffer seeds once on mount; the modal's destroyOnHidden makes -// each open a fresh editor instance, so we don't need to re-sync on outer -// form changes. -function AdvancedSliceEditor({ - form, - path, - wrapKey, - minHeight, - maxHeight, -}: { - form: FormInstance; - path: NamePath; - // When set, the editor wraps the inner value with `{ [wrapKey]: ... }` so - // the JSON the user sees matches the wire shape's slice envelope (e.g. - // `{ "settings": { ... } }`). Edits unwrap the outer key before writing - // back to the form. Mirrors the legacy modal's wrappedConfigValue. - wrapKey?: string; - minHeight?: string; - maxHeight?: string; -}) { - const serialize = (value: unknown): string => { - const inner = value ?? {}; - return JSON.stringify(wrapKey ? { [wrapKey]: inner } : inner, null, 2); - }; - - // preserve: true so useWatch returns the full subtree from the form - // store — without it, useWatch goes through getFieldsValue() which - // filters out unregistered fields. Slices like `settings` would lose - // their `clients` / `fallbacks` sub-trees because those aren't bound - // to any Form.Item. - const watched = Form.useWatch(path, { form, preserve: true }); - const lastEmitRef = useRef(''); - const [text, setText] = useState(() => { - const initial = serialize(form.getFieldValue(path)); - lastEmitRef.current = initial; - return initial; - }); - - useEffect(() => { - const formStr = serialize(watched); - if (formStr === lastEmitRef.current) return; - setText(formStr); - lastEmitRef.current = formStr; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [watched, wrapKey]); - - return ( - { - setText(next); - try { - const parsed = JSON.parse(next); - const toWrite = wrapKey && parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? (parsed as Record)[wrapKey] ?? {} - : parsed; - form.setFieldValue(path, toWrite); - lastEmitRef.current = JSON.stringify(wrapKey ? { [wrapKey]: toWrite } : toWrite, 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; -}) { - // preserve: true — default useWatch returns only registered fields, so - // sub-trees we never bound (settings.clients/fallbacks, sniffing - // defaults, etc.) wouldn't show up. preserve switches the read to - // getFieldsValue(true) which returns the full form store. - const wListen = Form.useWatch('listen', { form, preserve: true }); - const wPort = Form.useWatch('port', { form, preserve: true }); - const wProtocol = Form.useWatch('protocol', { form, preserve: true }); - const wTag = Form.useWatch('tag', { form, preserve: true }); - const wSettings = Form.useWatch('settings', { form, preserve: true }); - const wSniffing = Form.useWatch('sniffing', { form, preserve: true }); - const wStream = Form.useWatch('streamSettings', { form, preserve: true }); - - const serialize = () => { - // Apply the same prune/normalize as the wire payload so the JSON - // shown here is what the panel actually POSTs (no empty defaults, - // disabled sniffing as { enabled: false }, finalmask dropped when - // there are no masks). - const settingsView = (pruneEmpty(wSettings ?? {}) ?? {}) as Record; - if (typeof wProtocol === 'string' && Array.isArray(settingsView.clients)) { - settingsView.clients = normalizeClients(wProtocol, settingsView.clients); - } - const streamView = streamEnabled - ? ((pruneEmpty(wStream ?? {}) ?? {}) as Record) - : undefined; - dropLegacyOptionalEmpties(settingsView, streamView); - const out: Record = { - listen: wListen ?? '', - port: wPort ?? 0, - protocol: wProtocol ?? '', - tag: wTag ?? '', - settings: settingsView, - sniffing: normalizeSniffing(wSniffing as Parameters[0]), - }; - if (streamView) out.streamSettings = streamView; - 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; diff --git a/frontend/src/pages/inbounds/form/advanced-editors.tsx b/frontend/src/pages/inbounds/form/advanced-editors.tsx new file mode 100644 index 00000000..3dbd312d --- /dev/null +++ b/frontend/src/pages/inbounds/form/advanced-editors.tsx @@ -0,0 +1,184 @@ +import { useEffect, useRef, useState } from 'react'; +import { Form, type FormInstance } from 'antd'; +import type { NamePath } from 'antd/es/form/interface'; + +import { JsonEditor } from '@/components/form'; +import { + pruneEmpty, + normalizeSniffing, + normalizeClients, + dropLegacyOptionalEmpties, +} from '@/lib/xray/inbound-form-adapter'; +import type { InboundFormValues } from '@/schemas/forms/inbound-form'; + +// Sub-editor for one slice of the form (settings, streamSettings, sniffing). +// Holds a local text buffer so the user can type freely; on every keystroke +// we try to JSON.parse and forward the result to form state. Invalid JSON +// is held in the buffer until the next valid moment — no panic on partial +// input. The buffer seeds once on mount; the modal's destroyOnHidden makes +// each open a fresh editor instance, so we don't need to re-sync on outer +// form changes. +export function AdvancedSliceEditor({ + form, + path, + wrapKey, + minHeight, + maxHeight, +}: { + form: FormInstance; + path: NamePath; + // When set, the editor wraps the inner value with `{ [wrapKey]: ... }` so + // the JSON the user sees matches the wire shape's slice envelope (e.g. + // `{ "settings": { ... } }`). Edits unwrap the outer key before writing + // back to the form. Mirrors the legacy modal's wrappedConfigValue. + wrapKey?: string; + minHeight?: string; + maxHeight?: string; +}) { + const serialize = (value: unknown): string => { + const inner = value ?? {}; + return JSON.stringify(wrapKey ? { [wrapKey]: inner } : inner, null, 2); + }; + + // preserve: true so useWatch returns the full subtree from the form + // store — without it, useWatch goes through getFieldsValue() which + // filters out unregistered fields. Slices like `settings` would lose + // their `clients` / `fallbacks` sub-trees because those aren't bound + // to any Form.Item. + const watched = Form.useWatch(path, { form, preserve: true }); + const lastEmitRef = useRef(''); + const [text, setText] = useState(() => { + const initial = serialize(form.getFieldValue(path)); + lastEmitRef.current = initial; + return initial; + }); + + useEffect(() => { + const formStr = serialize(watched); + if (formStr === lastEmitRef.current) return; + setText(formStr); + lastEmitRef.current = formStr; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [watched, wrapKey]); + + return ( + { + setText(next); + try { + const parsed = JSON.parse(next); + const toWrite = wrapKey && parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record)[wrapKey] ?? {} + : parsed; + form.setFieldValue(path, toWrite); + lastEmitRef.current = JSON.stringify(wrapKey ? { [wrapKey]: toWrite } : toWrite, 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. +export function AdvancedAllEditor({ + form, + streamEnabled, +}: { + form: FormInstance; + streamEnabled: boolean; +}) { + // preserve: true — default useWatch returns only registered fields, so + // sub-trees we never bound (settings.clients/fallbacks, sniffing + // defaults, etc.) wouldn't show up. preserve switches the read to + // getFieldsValue(true) which returns the full form store. + const wListen = Form.useWatch('listen', { form, preserve: true }); + const wPort = Form.useWatch('port', { form, preserve: true }); + const wProtocol = Form.useWatch('protocol', { form, preserve: true }); + const wTag = Form.useWatch('tag', { form, preserve: true }); + const wSettings = Form.useWatch('settings', { form, preserve: true }); + const wSniffing = Form.useWatch('sniffing', { form, preserve: true }); + const wStream = Form.useWatch('streamSettings', { form, preserve: true }); + + const serialize = () => { + // Apply the same prune/normalize as the wire payload so the JSON + // shown here is what the panel actually POSTs (no empty defaults, + // disabled sniffing as { enabled: false }, finalmask dropped when + // there are no masks). + const settingsView = (pruneEmpty(wSettings ?? {}) ?? {}) as Record; + if (typeof wProtocol === 'string' && Array.isArray(settingsView.clients)) { + settingsView.clients = normalizeClients(wProtocol, settingsView.clients); + } + const streamView = streamEnabled + ? ((pruneEmpty(wStream ?? {}) ?? {}) as Record) + : undefined; + dropLegacyOptionalEmpties(settingsView, streamView); + const out: Record = { + listen: wListen ?? '', + port: wPort ?? 0, + protocol: wProtocol ?? '', + tag: wTag ?? '', + settings: settingsView, + sniffing: normalizeSniffing(wSniffing as Parameters[0]), + }; + if (streamView) out.streamSettings = streamView; + 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; + }} + /> + ); +}