mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
refactor(frontend): extract InboundFormModal advanced JSON editors
InboundFormModal.tsx 3129 -> 2863. Moved AdvancedSliceEditor and AdvancedAllEditor (the in-modal JSON slice/all editors) into advanced-editors.tsx along with their adapter-helper imports. Per-protocol render snapshots unchanged -> verified no behavior change. typecheck/lint/build green.
This commit is contained in:
parent
1ca0e10151
commit
42f943ddc8
2 changed files with 186 additions and 178 deletions
|
|
@ -33,10 +33,6 @@ import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from
|
||||||
import {
|
import {
|
||||||
rawInboundToFormValues,
|
rawInboundToFormValues,
|
||||||
formValuesToWirePayload,
|
formValuesToWirePayload,
|
||||||
pruneEmpty,
|
|
||||||
normalizeSniffing,
|
|
||||||
normalizeClients,
|
|
||||||
dropLegacyOptionalEmpties,
|
|
||||||
} from '@/lib/xray/inbound-form-adapter';
|
} from '@/lib/xray/inbound-form-adapter';
|
||||||
import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
|
import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
|
||||||
import {
|
import {
|
||||||
|
|
@ -85,10 +81,9 @@ import { FinalMaskForm } from '@/lib/xray/forms/transport';
|
||||||
import { HeaderMapEditor } from '@/components/form';
|
import { HeaderMapEditor } from '@/components/form';
|
||||||
import { HysteriaMasqueradeForm } from '@/lib/xray/forms/protocols/shared';
|
import { HysteriaMasqueradeForm } from '@/lib/xray/forms/protocols/shared';
|
||||||
import { InputAddon } from '@/components/ui';
|
import { InputAddon } from '@/components/ui';
|
||||||
import { JsonEditor } from '@/components/form';
|
|
||||||
import './InboundFormModal.css';
|
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;
|
const { TextArea } = Input;
|
||||||
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
|
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
|
||||||
|
|
@ -101,177 +96,6 @@ import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||||
|
|
||||||
const { Text } = Typography;
|
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<InboundFormValues>;
|
|
||||||
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<string>('');
|
|
||||||
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 (
|
|
||||||
<JsonEditor
|
|
||||||
value={text}
|
|
||||||
minHeight={minHeight}
|
|
||||||
maxHeight={maxHeight}
|
|
||||||
onChange={(next) => {
|
|
||||||
setText(next);
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(next);
|
|
||||||
const toWrite = wrapKey && parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
||||||
? (parsed as Record<string, unknown>)[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<InboundFormValues>;
|
|
||||||
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<string, unknown>;
|
|
||||||
if (typeof wProtocol === 'string' && Array.isArray(settingsView.clients)) {
|
|
||||||
settingsView.clients = normalizeClients(wProtocol, settingsView.clients);
|
|
||||||
}
|
|
||||||
const streamView = streamEnabled
|
|
||||||
? ((pruneEmpty(wStream ?? {}) ?? {}) as Record<string, unknown>)
|
|
||||||
: undefined;
|
|
||||||
dropLegacyOptionalEmpties(settingsView, streamView);
|
|
||||||
const out: Record<string, unknown> = {
|
|
||||||
listen: wListen ?? '',
|
|
||||||
port: wPort ?? 0,
|
|
||||||
protocol: wProtocol ?? '',
|
|
||||||
tag: wTag ?? '',
|
|
||||||
settings: settingsView,
|
|
||||||
sniffing: normalizeSniffing(wSniffing as Parameters<typeof normalizeSniffing>[0]),
|
|
||||||
};
|
|
||||||
if (streamView) out.streamSettings = streamView;
|
|
||||||
return JSON.stringify(out, null, 2);
|
|
||||||
};
|
|
||||||
|
|
||||||
const lastEmitRef = useRef<string>('');
|
|
||||||
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 (
|
|
||||||
<JsonEditor
|
|
||||||
value={text}
|
|
||||||
minHeight="340px"
|
|
||||||
maxHeight="560px"
|
|
||||||
onChange={(next) => {
|
|
||||||
setText(next);
|
|
||||||
let parsed: Record<string, unknown>;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(next) as Record<string, unknown>;
|
|
||||||
} 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 PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p }));
|
||||||
const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'] as const;
|
const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'] as const;
|
||||||
|
|
|
||||||
184
frontend/src/pages/inbounds/form/advanced-editors.tsx
Normal file
184
frontend/src/pages/inbounds/form/advanced-editors.tsx
Normal file
|
|
@ -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<InboundFormValues>;
|
||||||
|
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<string>('');
|
||||||
|
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 (
|
||||||
|
<JsonEditor
|
||||||
|
value={text}
|
||||||
|
minHeight={minHeight}
|
||||||
|
maxHeight={maxHeight}
|
||||||
|
onChange={(next) => {
|
||||||
|
setText(next);
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(next);
|
||||||
|
const toWrite = wrapKey && parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||||
|
? (parsed as Record<string, unknown>)[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<InboundFormValues>;
|
||||||
|
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<string, unknown>;
|
||||||
|
if (typeof wProtocol === 'string' && Array.isArray(settingsView.clients)) {
|
||||||
|
settingsView.clients = normalizeClients(wProtocol, settingsView.clients);
|
||||||
|
}
|
||||||
|
const streamView = streamEnabled
|
||||||
|
? ((pruneEmpty(wStream ?? {}) ?? {}) as Record<string, unknown>)
|
||||||
|
: undefined;
|
||||||
|
dropLegacyOptionalEmpties(settingsView, streamView);
|
||||||
|
const out: Record<string, unknown> = {
|
||||||
|
listen: wListen ?? '',
|
||||||
|
port: wPort ?? 0,
|
||||||
|
protocol: wProtocol ?? '',
|
||||||
|
tag: wTag ?? '',
|
||||||
|
settings: settingsView,
|
||||||
|
sniffing: normalizeSniffing(wSniffing as Parameters<typeof normalizeSniffing>[0]),
|
||||||
|
};
|
||||||
|
if (streamView) out.streamSettings = streamView;
|
||||||
|
return JSON.stringify(out, null, 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const lastEmitRef = useRef<string>('');
|
||||||
|
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 (
|
||||||
|
<JsonEditor
|
||||||
|
value={text}
|
||||||
|
minHeight="340px"
|
||||||
|
maxHeight="560px"
|
||||||
|
onChange={(next) => {
|
||||||
|
setText(next);
|
||||||
|
let parsed: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(next) as Record<string, unknown>;
|
||||||
|
} 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;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue