From d1882c7f297c07c9a24368c4ff49419e08e4ba85 Mon Sep 17 00:00:00 2001 From: Sanaei Date: Sat, 30 May 2026 21:51:33 +0200 Subject: [PATCH] refactor(frontend): reorganize source tree & break down oversized modals/tabs (#4698) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(frontend): reorganize components & pages into feature folders No behavior change; pure file relocation + import path updates. * refactor(frontend): move shared protocol enums to schemas/protocols/shared Decouple Outbound from Inbound schemas: SSMethodSchema and VmessSecuritySchema (shared between inbound & outbound) now live in a neutral schemas/protocols/shared/ module. Outbound no longer reaches into schemas/protocols/inbound/*. Pure relocation + import rewiring; schema values identical, snapshots & golden tests unchanged. * refactor(frontend): break InboundList into helpers/types/RowActions/columns hook/stats modal InboundList.tsx 781 -> 203 lines. Extracted pure helpers (network labels, sort fns, isInboundMultiUser), shared types, the row-actions menu/cell, the table columns hook, and the mobile stats modal into the list/ folder. Code moved verbatim; no behavior change. typecheck/lint/test/build green, 337 tests pass. * refactor(frontend): extract InboundInfoModal helpers, types & buildInboundInfo InboundInfoModal.tsx 1081 -> 836 lines. Moved the pure data helpers (network host/path readers, link-protocol check, copy/download/statsColor/IP formatting) plus all shared types and the buildInboundInfo data builder into info/helpers.ts and info/types.ts. The state-coupled render body is left intact (no React render tests to guard a deeper split). Code moved verbatim; no behavior change. All gates green, 337 tests pass. * test(frontend): add React Testing Library + jsdom render-test harness - vitest projects: node unit tests stay lean; new jsdom 'components' project runs *.test.tsx - component setup: matchMedia/ResizeObserver/localStorage polyfills, react-i18next init, persian-calendar-suite stub (only used under jalali locale) - smoke + field-label structure snapshots for Inbound & Outbound form modals - establishes the regression net required before decomposing the oversized form modals - 341 tests pass (337 unit + 4 component); typecheck/lint/build green * test(frontend): per-protocol field-structure coverage for both form modals - drive the protocol Select in jsdom and snapshot rendered Form.Item labels for every protocol - 10 outbound + 10 inbound protocol states captured as the regression net for protocol-core extraction - add robust select-driving helpers (test-utils) + post-test body cleanup (setup.components) - 341 tests pass; typecheck/lint green * refactor(frontend): extract OutboundFormModal constants & stream helpers OutboundFormModal.tsx 2238 -> 2080. Moved the pure option arrays/sets and the stream-slice helpers (newStreamSlice, hysteriaStreamSlice, isMuxAllowed, buildAddModeValues) into outbound-form-constants.ts and outbound-form-helpers.ts. Per-protocol render snapshots unchanged -> verified no behavior change. typecheck/lint/build green. * 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. * refactor(frontend): extract OutboundFormModal loopback/blackhole/dns field blocks Moved the outbound-only protocol field blocks (loopback, blackhole, dns) out of the modal render body into outbound-only-fields.tsx. First render-body extraction behind the per-protocol snapshot net: loopback/blackhole/dns snapshots unchanged -> verified no behavior change. typecheck/lint/build green. * refactor(frontend): extract OutboundFormModal freedom field block OutboundFormModal.tsx 2063 -> 1753. Moved the freedom protocol field block (domainStrategy, fragment, noises, finalRules) into outbound-freedom-fields.tsx. Verbatim relocation; freedom per-protocol snapshot unchanged -> no behavior change. typecheck/lint/build green. * refactor(frontend): extract OutboundFormModal wireguard field block OutboundFormModal.tsx 1753 -> 1622. Moved the wireguard protocol field block (address, keypair gen, domainStrategy, peers + allowedIPs) into outbound-wireguard-fields.tsx; dropped now-unused icon/InputAddon/WireguardDomainStrategy imports. Verbatim relocation; wireguard snapshot unchanged -> no behavior change. typecheck/lint/build green. * refactor(frontend): extract OutboundFormModal core protocol fields OutboundFormModal.tsx 1622 -> 1538. Moved the shared protocol core field blocks (vmess/vless ID, vmess security, vless encryption/reverseTag, trojan/ss password, ss method/uot, socks/http user/pass) into outbound-core-fields.tsx; dropped now-unused schema/option imports. Per-protocol snapshots unchanged -> no behavior change. typecheck/lint/build green. * refactor(frontend): fold OutboundFormModal server address/port block into core fields OutboundFormModal.tsx 1538 -> 1516. Moved the shared connect-target (address/port) block into OutboundCoreProtocolFields at the same render position; dropped the now-unused SERVER_PROTOCOLS import. Snapshots unchanged -> no behavior change. typecheck/lint/build green. * refactor(frontend): split outbound-only protocol forms into per-protocol files Replace the grouped outbound-only-fields.tsx + outbound-freedom-fields.tsx with one file per protocol under outbounds/protocols/: freedom.tsx, blackhole.tsx, dns.tsx, loopback.tsx (+ barrel). Matches the prompt's 1-file-per-protocol structure. Outbound snapshots unchanged -> no behavior change. typecheck/lint/build green. * refactor(frontend): split outbound protocol forms into per-protocol files Replace the grouped outbound-core-fields / outbound-wireguard-fields with one file per protocol under outbounds/protocols/: vmess, vless, trojan, shadowsocks, http, socks, wireguard, freedom, blackhole, dns, loopback (+ shared server-target). Matches the prompt's 1-file-per-protocol structure (per-modal). Outbound snapshots unchanged -> no behavior change. typecheck/lint/build green. * refactor(frontend): split outbound transport forms into per-transport files Extract the tcp(raw)/kcp/ws/grpc/httpupgrade transport blocks into outbounds/transport/ per-file components (RawForm, KcpForm, WsForm, GrpcForm, HttpUpgradeForm). xhttp + hysteria transport remain inline for a follow-up. Verbatim relocation; outbound snapshots unchanged -> no behavior change. typecheck/lint/build green. * refactor(frontend): extract OutboundFormModal xhttp transport form Move the xhttp transport block into transport/xhttp.tsx (takes form + onXmuxToggle prop); drop now-unused HeaderMapEditor and MODE_OPTIONS imports from the modal. OutboundFormModal.tsx down to ~1001 lines (from 2238 originally). Verbatim relocation; outbound snapshots unchanged -> no behavior change. typecheck/lint/build green. * refactor(frontend): extract OutboundFormModal tls/reality security forms Move the TLS and Reality field blocks into outbounds/security/{tls,reality}.tsx; the none/TLS/Reality Radio.Group selector stays in the modal. Drop now-unused ALPN_OPTIONS/UTLS_OPTIONS imports. OutboundFormModal.tsx down to ~918 lines (from 2238 originally). Verbatim relocation; outbound snapshots unchanged -> no behavior change. typecheck/lint/build green. * refactor(frontend): split inbound-only protocol forms (tun, tunnel) into per-file Extract the tun and tunnel protocol blocks from InboundFormModal into inbounds/form/protocols/{tun,tunnel}.tsx (presentational, declarative). First inbound-side per-protocol split. Verbatim relocation; inbound snapshots unchanged -> no behavior change. typecheck/lint/build green. * refactor(frontend): split inbound wireguard & shadowsocks protocol forms Extract the wireguard and shadowsocks protocol blocks from InboundFormModal into inbounds/form/protocols/{wireguard,shadowsocks}.tsx (presentational; form + regen handlers / isSSWith2022 passed as props). Drop now-unused Divider + SSMethodSchema imports. Verbatim relocation; inbound snapshots unchanged -> no behavior change. typecheck/lint/build green. * refactor(frontend): split inbound vless/http/mixed/hysteria protocol forms Extract the remaining inbound protocol blocks into inbounds/form/protocols/: vless (auth handlers/state as props), http + mixed (shared accounts-list), hysteria. Drop now-unused HysteriaMasqueradeForm/Typography/Text imports from the modal. InboundFormModal.tsx 2841 -> 2478. Inbound snapshots unchanged -> no behavior change. typecheck/lint/build green. * refactor(frontend): move HysteriaMasqueradeForm to lib/xray/forms/transport The hysteria masquerade form edits streamSettings.hysteriaSettings.masquerade (a transport/stream concept) and is rendered identically by both modals, so it belongs next to FinalMaskForm in lib/xray/forms/transport/ rather than protocols/shared/. Moved the file, updated the transport barrel + both consumers (inbound hysteria protocol form, outbound modal), and removed the now-empty protocols/shared/ folder. Pure relocation; snapshots unchanged, typecheck/lint/build green. * refactor(frontend): extract inbound transport forms into transport/ folder Move the six inbound stream-transport blocks (tcp/raw, ws, grpc, xhttp, httpupgrade, kcp) out of InboundFormModal into presentational components under inbounds/form/transport/. XhttpForm takes the form instance and re-derives its mode/obfs/placement watches internally; the rest are declarative. InboundFormModal drops from 2566 to 2105 lines. No behavior change — per-protocol field-label snapshots unchanged. * refactor(frontend): extract inbound security forms into security/ folder Move the inbound TLS and Reality stream-security blocks out of InboundFormModal into presentational components under inbounds/form/security/. The Radio.Group security selector stays in the modal; TlsForm and RealityForm receive their cert/key/ECH generation handlers and the saving flag as props. InboundFormModal drops from 2105 to 1708 lines. Add inbound-form-blocks.test.tsx: render-snapshot coverage for each extracted transport (raw/ws/grpc/kcp/httpupgrade/xhttp) and security (tls/reality) component in isolation inside a minimal Form. The full modal cannot exercise the stream/security tabs in jsdom because they are gated behind Form.useWatch values that do not propagate in the test harness, so component-level snapshots are the regression net for these blocks. No behavior change. * refactor(frontend): extract outbound sockopt/mux/hysteria transport blocks Move the last three oversized inline stream blocks out of OutboundFormModal into presentational components under xray/outbounds/transport/: SockoptForm (~260 lines, the worst offender), MuxForm, and HysteriaForm. Each takes the form instance; MuxForm also takes protocol/network and keeps its isMuxAllowed gate. OutboundFormModal drops from 962 to 621 lines and no inline section now exceeds the 250-line guideline. Existing outbound-form-modal snapshots already cover sockopt/mux and stay byte-identical, confirming no behavior change. * refactor(frontend): extract inbound sockopt + external-proxy blocks Move the inbound Sockopt (~250 lines) and External Proxy stream blocks out of InboundFormModal into presentational components under inbounds/form/transport/, mirroring the outbound extraction. Each takes its toggle handler (toggleSockopt / toggleExternalProxy) as a prop and keeps its render-prop getFieldValue gate. InboundFormModal drops from 1708 to 1332 lines. Extend inbound-form-blocks.test.tsx with isolated render-snapshot coverage for both (SockoptForm seeded enabled + happyEyeballs; ExternalProxyForm seeded with one TLS entry). No behavior change. * refactor(frontend): break down RoutingTab into sections Extract RoutingTab's presentational pieces into the routing/ folder: helpers.ts (arrJoin/csv/chipPreview/ruleCriteriaChips), types.ts (RuleRow), CriterionRow.tsx, RuleCardList.tsx (mobile card view), and useRoutingColumns.tsx (desktop table columns hook). RoutingTab stays the orchestrator holding rule state, mutate, tag-option memos and the pointer-drag reorder logic, and drops from 550 to 291 lines. No behavior change. * refactor(frontend): extract BasicsTab constants and rule helpers Move BasicsTab's geo option arrays + freedom/ipv4 outbound presets into basics/constants.ts and the routing-rule get/set/sync helpers into basics/helpers.ts. BasicsTab drops from 550 to 447 lines and keeps its Collapse-of-settings panels (which stay coupled to mutate + derived state, so splitting them into components would only add prop-drilling). No behavior change. * refactor(frontend): break down DnsTab columns/helpers/types Extract DnsTab's pure pieces into the dns/ folder: helpers.ts (STRATEGIES/DEFAULT_FAKEDNS + addr/domains/expectedIPs accessors), types.ts (DnsConfig/HostRow/FakednsRow), and useDnsColumns.tsx (useDnsServerColumns + useFakednsColumns table-column hooks taking their row handlers as params). DnsTab stays the orchestrator for dns state, mutate, hosts sync and the Collapse panels, and drops from 539 to 424 lines. No behavior change. * refactor(frontend): break down OutboundsTab into sections Extract OutboundsTab's pieces: outbounds-tab-types.ts (OutboundRow), outbounds-tab-helpers.ts (address/untestable/security/breakdown + traffic/testing/result accessors), useOutboundColumns.tsx (desktop table columns hook) and OutboundCardList.tsx (mobile card view). OutboundsTab stays the orchestrator for outbound state, mutate, reorder and the toolbar, and drops from 516 to 238 lines. No behavior change. This completes plan section 2.4.5 — all four oversized Xray tabs (Basics/Routing/Dns/Outbounds) are now broken into sections + hooks. * refactor(frontend): fold HysteriaMasqueradeForm into the hysteria forms Inline the masquerade fields directly into both hysteria transport forms (inbounds/form/protocols/hysteria + xray/outbounds/transport/hysteria) and delete the shared lib/xray/forms/transport/HysteriaMasqueradeForm so each hysteria form is self-contained. The masquerade JSX is unchanged; form is typed as the untyped FormInstance (as the shared component was) so the masquerade name paths still resolve. No behavior change. * refactor(frontend): slim InboundFormModal by extracting hooks + sections Pull the modal's non-layout logic into focused files at the form root: - useSecurityActions.ts: TLS/Reality key + cert generation handlers and onSecurityChange (consumed by the security tab) - useInboundFallbacks.ts: fallback row state + load/save/derive/add/ update/remove/move handlers + eligible-child options - FallbacksCard.tsx: the fallbacks card UI (presentational) - SniffingTab.tsx: the sniffing tab UI (presentational) Also drop the stale "Pattern A rewrite / sibling file" header comment and the imports the extractions made unused. InboundFormModal goes from 1332 to 868 lines with no behavior change (351 tests green, snapshots unchanged). --- frontend/package-lock.json | 662 ++++ frontend/package.json | 3 + .../components/{ => feedback}/PromptModal.tsx | 0 .../components/{ => feedback}/TextModal.tsx | 0 frontend/src/components/feedback/index.ts | 2 + .../components/{ => form}/DateTimePicker.css | 0 .../components/{ => form}/DateTimePicker.tsx | 0 .../components/{ => form}/HeaderMapEditor.tsx | 2 +- .../src/components/{ => form}/JsonEditor.css | 0 .../src/components/{ => form}/JsonEditor.tsx | 0 frontend/src/components/form/index.ts | 3 + .../src/components/{ => ui}/InfinityIcon.tsx | 0 .../src/components/{ => ui}/InputAddon.css | 0 .../src/components/{ => ui}/InputAddon.tsx | 0 .../components/{ => ui}/SettingListItem.css | 0 .../components/{ => ui}/SettingListItem.tsx | 0 frontend/src/components/ui/index.ts | 3 + .../components/{ => utility}/LazyMount.tsx | 0 frontend/src/components/utility/index.ts | 1 + .../src/components/{ => viz}/Sparkline.css | 0 .../src/components/{ => viz}/Sparkline.tsx | 0 frontend/src/components/viz/index.ts | 1 + .../{components => layouts}/AppSidebar.css | 0 .../{components => layouts}/AppSidebar.tsx | 0 .../xray/forms/transport}/FinalMaskForm.tsx | 0 .../src/lib/xray/forms/transport/index.ts | 1 + frontend/src/lib/xray/inbound-link.ts | 2 +- frontend/src/pages/api-docs/ApiDocsPage.tsx | 2 +- .../src/pages/clients/ClientBulkAddModal.tsx | 2 +- .../src/pages/clients/ClientFormModal.tsx | 2 +- .../src/pages/clients/ClientInfoModal.tsx | 2 +- frontend/src/pages/clients/ClientQrModal.tsx | 2 +- frontend/src/pages/clients/ClientsPage.tsx | 4 +- frontend/src/pages/groups/GroupsPage.tsx | 4 +- .../src/pages/inbounds/InboundFormModal.tsx | 3129 ----------------- frontend/src/pages/inbounds/InboundList.tsx | 781 ---- frontend/src/pages/inbounds/InboundsPage.tsx | 22 +- .../{ => clients}/AddClientsToGroupModal.tsx | 0 .../{ => clients}/AttachClientsModal.tsx | 2 +- .../{ => clients}/DetachClientsModal.tsx | 0 frontend/src/pages/inbounds/clients/index.ts | 3 + .../src/pages/inbounds/form/FallbacksCard.tsx | 123 + .../inbounds/{ => form}/InboundFormModal.css | 0 .../pages/inbounds/form/InboundFormModal.tsx | 868 +++++ .../src/pages/inbounds/form/SniffingTab.tsx | 67 + .../pages/inbounds/form/advanced-editors.tsx | 184 + frontend/src/pages/inbounds/form/index.ts | 1 + .../inbounds/form/protocols/accounts-list.tsx | 47 + .../pages/inbounds/form/protocols/http.tsx | 20 + .../inbounds/form/protocols/hysteria.tsx} | 24 +- .../pages/inbounds/form/protocols/index.ts | 8 + .../pages/inbounds/form/protocols/mixed.tsx | 33 + .../inbounds/form/protocols/shadowsocks.tsx | 67 + .../src/pages/inbounds/form/protocols/tun.tsx | 93 + .../pages/inbounds/form/protocols/tunnel.tsx | 37 + .../pages/inbounds/form/protocols/vless.tsx | 60 + .../inbounds/form/protocols/wireguard.tsx | 120 + .../src/pages/inbounds/form/security/index.ts | 2 + .../pages/inbounds/form/security/reality.tsx | 143 + .../src/pages/inbounds/form/security/tls.tsx | 309 ++ .../form/transport/external-proxy.tsx | 136 + .../pages/inbounds/form/transport/grpc.tsx | 29 + .../inbounds/form/transport/httpupgrade.tsx | 37 + .../pages/inbounds/form/transport/index.ts | 8 + .../src/pages/inbounds/form/transport/kcp.tsx | 34 + .../src/pages/inbounds/form/transport/raw.tsx | 164 + .../pages/inbounds/form/transport/sockopt.tsx | 270 ++ .../src/pages/inbounds/form/transport/ws.tsx | 37 + .../pages/inbounds/form/transport/xhttp.tsx | 218 ++ .../inbounds/form/useInboundFallbacks.ts | 187 + .../pages/inbounds/form/useSecurityActions.ts | 205 ++ .../inbounds/{ => info}/InboundInfoModal.css | 0 .../inbounds/{ => info}/InboundInfoModal.tsx | 274 +- frontend/src/pages/inbounds/info/helpers.ts | 170 + frontend/src/pages/inbounds/info/index.ts | 1 + frontend/src/pages/inbounds/info/types.ts | 87 + .../pages/inbounds/{ => list}/InboundList.css | 0 .../src/pages/inbounds/list/InboundList.tsx | 203 ++ .../pages/inbounds/list/InboundStatsModal.tsx | 141 + .../src/pages/inbounds/list/RowActions.tsx | 81 + frontend/src/pages/inbounds/list/helpers.ts | 106 + frontend/src/pages/inbounds/list/index.ts | 2 + frontend/src/pages/inbounds/list/types.ts | 88 + .../pages/inbounds/list/useInboundColumns.tsx | 290 ++ .../pages/inbounds/{ => qr}/QrCodeModal.tsx | 2 +- .../src/pages/inbounds/{ => qr}/QrPanel.css | 0 .../src/pages/inbounds/{ => qr}/QrPanel.tsx | 0 frontend/src/pages/inbounds/qr/index.ts | 2 + frontend/src/pages/index/IndexPage.tsx | 6 +- .../src/pages/index/SystemHistoryModal.tsx | 2 +- frontend/src/pages/index/XrayMetricsModal.tsx | 2 +- frontend/src/pages/nodes/NodeHistoryPanel.tsx | 2 +- frontend/src/pages/nodes/NodesPage.tsx | 2 +- frontend/src/pages/settings/GeneralTab.tsx | 2 +- frontend/src/pages/settings/SecurityTab.tsx | 2 +- frontend/src/pages/settings/SettingsPage.tsx | 2 +- .../pages/settings/SubscriptionFormatsTab.tsx | 2 +- .../pages/settings/SubscriptionGeneralTab.tsx | 2 +- frontend/src/pages/settings/TelegramTab.tsx | 2 +- frontend/src/pages/xray/OutboundFormModal.tsx | 2238 ------------ frontend/src/pages/xray/OutboundsTab.tsx | 516 --- frontend/src/pages/xray/RoutingTab.tsx | 550 --- frontend/src/pages/xray/XrayPage.tsx | 17 +- .../{ => balancers}/BalancerFormModal.tsx | 2 +- .../xray/{ => balancers}/BalancersTab.tsx | 2 +- frontend/src/pages/xray/balancers/index.ts | 2 + .../src/pages/xray/{ => basics}/BasicsTab.css | 0 .../src/pages/xray/{ => basics}/BasicsTab.tsx | 137 +- frontend/src/pages/xray/basics/constants.ts | 63 + frontend/src/pages/xray/basics/helpers.ts | 56 + frontend/src/pages/xray/basics/index.ts | 1 + .../pages/xray/{ => dns}/DnsPresetsModal.css | 0 .../pages/xray/{ => dns}/DnsPresetsModal.tsx | 0 .../pages/xray/{ => dns}/DnsServerModal.tsx | 2 +- frontend/src/pages/xray/{ => dns}/DnsTab.css | 0 frontend/src/pages/xray/{ => dns}/DnsTab.tsx | 133 +- frontend/src/pages/xray/dns/helpers.ts | 19 + frontend/src/pages/xray/dns/index.ts | 3 + frontend/src/pages/xray/dns/types.ts | 14 + frontend/src/pages/xray/dns/useDnsColumns.tsx | 122 + .../pages/xray/outbounds/OutboundCardList.tsx | 127 + .../{ => outbounds}/OutboundFormModal.css | 0 .../xray/outbounds/OutboundFormModal.tsx | 621 ++++ .../xray/{ => outbounds}/OutboundsTab.css | 0 .../src/pages/xray/outbounds/OutboundsTab.tsx | 238 ++ frontend/src/pages/xray/outbounds/index.ts | 2 + .../xray/outbounds/outbound-form-constants.ts | 47 + .../xray/outbounds/outbound-form-helpers.ts | 79 + .../xray/outbounds/outbounds-tab-helpers.ts | 62 + .../xray/outbounds/outbounds-tab-types.ts | 7 + .../xray/outbounds/protocols/blackhole.tsx | 17 + .../pages/xray/outbounds/protocols/dns.tsx | 70 + .../xray/outbounds/protocols/freedom.tsx | 265 ++ .../pages/xray/outbounds/protocols/http.tsx | 16 + .../pages/xray/outbounds/protocols/index.ts | 12 + .../xray/outbounds/protocols/loopback.tsx | 11 + .../outbounds/protocols/server-target.tsx | 24 + .../xray/outbounds/protocols/shadowsocks.tsx | 40 + .../pages/xray/outbounds/protocols/socks.tsx | 16 + .../pages/xray/outbounds/protocols/trojan.tsx | 18 + .../pages/xray/outbounds/protocols/vless.tsx | 33 + .../pages/xray/outbounds/protocols/vmess.tsx | 29 + .../xray/outbounds/protocols/wireguard.tsx | 142 + .../pages/xray/outbounds/security/index.ts | 2 + .../pages/xray/outbounds/security/reality.tsx | 48 + .../src/pages/xray/outbounds/security/tls.tsx | 52 + .../pages/xray/outbounds/transport/grpc.tsx | 29 + .../xray/outbounds/transport/httpupgrade.tsx | 30 + .../xray/outbounds/transport/hysteria.tsx | 134 + .../pages/xray/outbounds/transport/index.ts | 9 + .../pages/xray/outbounds/transport/kcp.tsx | 40 + .../pages/xray/outbounds/transport/mux.tsx | 63 + .../pages/xray/outbounds/transport/raw.tsx | 121 + .../xray/outbounds/transport/sockopt.tsx | 276 ++ .../src/pages/xray/outbounds/transport/ws.tsx | 30 + .../pages/xray/outbounds/transport/xhttp.tsx | 355 ++ .../xray/outbounds/useOutboundColumns.tsx | 217 ++ .../pages/xray/{ => overrides}/NordModal.css | 0 .../pages/xray/{ => overrides}/NordModal.tsx | 0 .../pages/xray/{ => overrides}/WarpModal.css | 0 .../pages/xray/{ => overrides}/WarpModal.tsx | 0 frontend/src/pages/xray/overrides/index.ts | 2 + .../src/pages/xray/routing/CriterionRow.tsx | 17 + .../pages/xray/{ => routing}/RoutingTab.css | 0 .../src/pages/xray/routing/RoutingTab.tsx | 291 ++ .../src/pages/xray/routing/RuleCardList.tsx | 118 + .../xray/{ => routing}/RuleFormModal.tsx | 2 +- frontend/src/pages/xray/routing/helpers.ts | 33 + frontend/src/pages/xray/routing/index.ts | 2 + frontend/src/pages/xray/routing/types.ts | 16 + .../pages/xray/routing/useRoutingColumns.tsx | 170 + frontend/src/schemas/forms/outbound-form.ts | 4 +- .../schemas/protocols/inbound/shadowsocks.ts | 11 +- .../src/schemas/protocols/inbound/vmess.ts | 18 +- .../schemas/protocols/outbound/shadowsocks.ts | 2 +- .../src/schemas/protocols/outbound/vmess.ts | 2 +- .../src/schemas/protocols/shared/index.ts | 2 + .../schemas/protocols/shared/shadowsocks.ts | 12 + .../src/schemas/protocols/shared/vmess.ts | 19 + .../inbound-form-blocks.test.tsx.snap | 134 + .../inbound-form-modal.test.tsx.snap | 141 + .../outbound-form-modal.test.tsx.snap | 141 + .../src/test/inbound-form-blocks.test.tsx | 128 + frontend/src/test/inbound-form-modal.test.tsx | 41 + .../src/test/outbound-form-modal.test.tsx | 39 + frontend/src/test/setup.components.ts | 64 + frontend/src/test/test-utils.tsx | 51 + frontend/vitest.config.ts | 25 +- 188 files changed, 10471 insertions(+), 7812 deletions(-) rename frontend/src/components/{ => feedback}/PromptModal.tsx (100%) rename frontend/src/components/{ => feedback}/TextModal.tsx (100%) create mode 100644 frontend/src/components/feedback/index.ts rename frontend/src/components/{ => form}/DateTimePicker.css (100%) rename frontend/src/components/{ => form}/DateTimePicker.tsx (100%) rename frontend/src/components/{ => form}/HeaderMapEditor.tsx (98%) rename frontend/src/components/{ => form}/JsonEditor.css (100%) rename frontend/src/components/{ => form}/JsonEditor.tsx (100%) create mode 100644 frontend/src/components/form/index.ts rename frontend/src/components/{ => ui}/InfinityIcon.tsx (100%) rename frontend/src/components/{ => ui}/InputAddon.css (100%) rename frontend/src/components/{ => ui}/InputAddon.tsx (100%) rename frontend/src/components/{ => ui}/SettingListItem.css (100%) rename frontend/src/components/{ => ui}/SettingListItem.tsx (100%) create mode 100644 frontend/src/components/ui/index.ts rename frontend/src/components/{ => utility}/LazyMount.tsx (100%) create mode 100644 frontend/src/components/utility/index.ts rename frontend/src/components/{ => viz}/Sparkline.css (100%) rename frontend/src/components/{ => viz}/Sparkline.tsx (100%) create mode 100644 frontend/src/components/viz/index.ts rename frontend/src/{components => layouts}/AppSidebar.css (100%) rename frontend/src/{components => layouts}/AppSidebar.tsx (100%) rename frontend/src/{components => lib/xray/forms/transport}/FinalMaskForm.tsx (100%) create mode 100644 frontend/src/lib/xray/forms/transport/index.ts delete mode 100644 frontend/src/pages/inbounds/InboundFormModal.tsx delete mode 100644 frontend/src/pages/inbounds/InboundList.tsx rename frontend/src/pages/inbounds/{ => clients}/AddClientsToGroupModal.tsx (100%) rename frontend/src/pages/inbounds/{ => clients}/AttachClientsModal.tsx (99%) rename frontend/src/pages/inbounds/{ => clients}/DetachClientsModal.tsx (100%) create mode 100644 frontend/src/pages/inbounds/clients/index.ts create mode 100644 frontend/src/pages/inbounds/form/FallbacksCard.tsx rename frontend/src/pages/inbounds/{ => form}/InboundFormModal.css (100%) create mode 100644 frontend/src/pages/inbounds/form/InboundFormModal.tsx create mode 100644 frontend/src/pages/inbounds/form/SniffingTab.tsx create mode 100644 frontend/src/pages/inbounds/form/advanced-editors.tsx create mode 100644 frontend/src/pages/inbounds/form/index.ts create mode 100644 frontend/src/pages/inbounds/form/protocols/accounts-list.tsx create mode 100644 frontend/src/pages/inbounds/form/protocols/http.tsx rename frontend/src/{components/HysteriaMasqueradeForm.tsx => pages/inbounds/form/protocols/hysteria.tsx} (85%) create mode 100644 frontend/src/pages/inbounds/form/protocols/index.ts create mode 100644 frontend/src/pages/inbounds/form/protocols/mixed.tsx create mode 100644 frontend/src/pages/inbounds/form/protocols/shadowsocks.tsx create mode 100644 frontend/src/pages/inbounds/form/protocols/tun.tsx create mode 100644 frontend/src/pages/inbounds/form/protocols/tunnel.tsx create mode 100644 frontend/src/pages/inbounds/form/protocols/vless.tsx create mode 100644 frontend/src/pages/inbounds/form/protocols/wireguard.tsx create mode 100644 frontend/src/pages/inbounds/form/security/index.ts create mode 100644 frontend/src/pages/inbounds/form/security/reality.tsx create mode 100644 frontend/src/pages/inbounds/form/security/tls.tsx create mode 100644 frontend/src/pages/inbounds/form/transport/external-proxy.tsx create mode 100644 frontend/src/pages/inbounds/form/transport/grpc.tsx create mode 100644 frontend/src/pages/inbounds/form/transport/httpupgrade.tsx create mode 100644 frontend/src/pages/inbounds/form/transport/index.ts create mode 100644 frontend/src/pages/inbounds/form/transport/kcp.tsx create mode 100644 frontend/src/pages/inbounds/form/transport/raw.tsx create mode 100644 frontend/src/pages/inbounds/form/transport/sockopt.tsx create mode 100644 frontend/src/pages/inbounds/form/transport/ws.tsx create mode 100644 frontend/src/pages/inbounds/form/transport/xhttp.tsx create mode 100644 frontend/src/pages/inbounds/form/useInboundFallbacks.ts create mode 100644 frontend/src/pages/inbounds/form/useSecurityActions.ts rename frontend/src/pages/inbounds/{ => info}/InboundInfoModal.css (100%) rename frontend/src/pages/inbounds/{ => info}/InboundInfoModal.tsx (80%) create mode 100644 frontend/src/pages/inbounds/info/helpers.ts create mode 100644 frontend/src/pages/inbounds/info/index.ts create mode 100644 frontend/src/pages/inbounds/info/types.ts rename frontend/src/pages/inbounds/{ => list}/InboundList.css (100%) create mode 100644 frontend/src/pages/inbounds/list/InboundList.tsx create mode 100644 frontend/src/pages/inbounds/list/InboundStatsModal.tsx create mode 100644 frontend/src/pages/inbounds/list/RowActions.tsx create mode 100644 frontend/src/pages/inbounds/list/helpers.ts create mode 100644 frontend/src/pages/inbounds/list/index.ts create mode 100644 frontend/src/pages/inbounds/list/types.ts create mode 100644 frontend/src/pages/inbounds/list/useInboundColumns.tsx rename frontend/src/pages/inbounds/{ => qr}/QrCodeModal.tsx (98%) rename frontend/src/pages/inbounds/{ => qr}/QrPanel.css (100%) rename frontend/src/pages/inbounds/{ => qr}/QrPanel.tsx (100%) create mode 100644 frontend/src/pages/inbounds/qr/index.ts delete mode 100644 frontend/src/pages/xray/OutboundFormModal.tsx delete mode 100644 frontend/src/pages/xray/OutboundsTab.tsx delete mode 100644 frontend/src/pages/xray/RoutingTab.tsx rename frontend/src/pages/xray/{ => balancers}/BalancerFormModal.tsx (99%) rename frontend/src/pages/xray/{ => balancers}/BalancersTab.tsx (99%) create mode 100644 frontend/src/pages/xray/balancers/index.ts rename frontend/src/pages/xray/{ => basics}/BasicsTab.css (100%) rename frontend/src/pages/xray/{ => basics}/BasicsTab.tsx (73%) create mode 100644 frontend/src/pages/xray/basics/constants.ts create mode 100644 frontend/src/pages/xray/basics/helpers.ts create mode 100644 frontend/src/pages/xray/basics/index.ts rename frontend/src/pages/xray/{ => dns}/DnsPresetsModal.css (100%) rename frontend/src/pages/xray/{ => dns}/DnsPresetsModal.tsx (100%) rename frontend/src/pages/xray/{ => dns}/DnsServerModal.tsx (99%) rename frontend/src/pages/xray/{ => dns}/DnsTab.css (100%) rename frontend/src/pages/xray/{ => dns}/DnsTab.tsx (78%) create mode 100644 frontend/src/pages/xray/dns/helpers.ts create mode 100644 frontend/src/pages/xray/dns/index.ts create mode 100644 frontend/src/pages/xray/dns/types.ts create mode 100644 frontend/src/pages/xray/dns/useDnsColumns.tsx create mode 100644 frontend/src/pages/xray/outbounds/OutboundCardList.tsx rename frontend/src/pages/xray/{ => outbounds}/OutboundFormModal.css (100%) create mode 100644 frontend/src/pages/xray/outbounds/OutboundFormModal.tsx rename frontend/src/pages/xray/{ => outbounds}/OutboundsTab.css (100%) create mode 100644 frontend/src/pages/xray/outbounds/OutboundsTab.tsx create mode 100644 frontend/src/pages/xray/outbounds/index.ts create mode 100644 frontend/src/pages/xray/outbounds/outbound-form-constants.ts create mode 100644 frontend/src/pages/xray/outbounds/outbound-form-helpers.ts create mode 100644 frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts create mode 100644 frontend/src/pages/xray/outbounds/outbounds-tab-types.ts create mode 100644 frontend/src/pages/xray/outbounds/protocols/blackhole.tsx create mode 100644 frontend/src/pages/xray/outbounds/protocols/dns.tsx create mode 100644 frontend/src/pages/xray/outbounds/protocols/freedom.tsx create mode 100644 frontend/src/pages/xray/outbounds/protocols/http.tsx create mode 100644 frontend/src/pages/xray/outbounds/protocols/index.ts create mode 100644 frontend/src/pages/xray/outbounds/protocols/loopback.tsx create mode 100644 frontend/src/pages/xray/outbounds/protocols/server-target.tsx create mode 100644 frontend/src/pages/xray/outbounds/protocols/shadowsocks.tsx create mode 100644 frontend/src/pages/xray/outbounds/protocols/socks.tsx create mode 100644 frontend/src/pages/xray/outbounds/protocols/trojan.tsx create mode 100644 frontend/src/pages/xray/outbounds/protocols/vless.tsx create mode 100644 frontend/src/pages/xray/outbounds/protocols/vmess.tsx create mode 100644 frontend/src/pages/xray/outbounds/protocols/wireguard.tsx create mode 100644 frontend/src/pages/xray/outbounds/security/index.ts create mode 100644 frontend/src/pages/xray/outbounds/security/reality.tsx create mode 100644 frontend/src/pages/xray/outbounds/security/tls.tsx create mode 100644 frontend/src/pages/xray/outbounds/transport/grpc.tsx create mode 100644 frontend/src/pages/xray/outbounds/transport/httpupgrade.tsx create mode 100644 frontend/src/pages/xray/outbounds/transport/hysteria.tsx create mode 100644 frontend/src/pages/xray/outbounds/transport/index.ts create mode 100644 frontend/src/pages/xray/outbounds/transport/kcp.tsx create mode 100644 frontend/src/pages/xray/outbounds/transport/mux.tsx create mode 100644 frontend/src/pages/xray/outbounds/transport/raw.tsx create mode 100644 frontend/src/pages/xray/outbounds/transport/sockopt.tsx create mode 100644 frontend/src/pages/xray/outbounds/transport/ws.tsx create mode 100644 frontend/src/pages/xray/outbounds/transport/xhttp.tsx create mode 100644 frontend/src/pages/xray/outbounds/useOutboundColumns.tsx rename frontend/src/pages/xray/{ => overrides}/NordModal.css (100%) rename frontend/src/pages/xray/{ => overrides}/NordModal.tsx (100%) rename frontend/src/pages/xray/{ => overrides}/WarpModal.css (100%) rename frontend/src/pages/xray/{ => overrides}/WarpModal.tsx (100%) create mode 100644 frontend/src/pages/xray/overrides/index.ts create mode 100644 frontend/src/pages/xray/routing/CriterionRow.tsx rename frontend/src/pages/xray/{ => routing}/RoutingTab.css (100%) create mode 100644 frontend/src/pages/xray/routing/RoutingTab.tsx create mode 100644 frontend/src/pages/xray/routing/RuleCardList.tsx rename frontend/src/pages/xray/{ => routing}/RuleFormModal.tsx (99%) create mode 100644 frontend/src/pages/xray/routing/helpers.ts create mode 100644 frontend/src/pages/xray/routing/index.ts create mode 100644 frontend/src/pages/xray/routing/types.ts create mode 100644 frontend/src/pages/xray/routing/useRoutingColumns.tsx create mode 100644 frontend/src/schemas/protocols/shared/index.ts create mode 100644 frontend/src/schemas/protocols/shared/shadowsocks.ts create mode 100644 frontend/src/schemas/protocols/shared/vmess.ts create mode 100644 frontend/src/test/__snapshots__/inbound-form-blocks.test.tsx.snap create mode 100644 frontend/src/test/__snapshots__/inbound-form-modal.test.tsx.snap create mode 100644 frontend/src/test/__snapshots__/outbound-form-modal.test.tsx.snap create mode 100644 frontend/src/test/inbound-form-blocks.test.tsx create mode 100644 frontend/src/test/inbound-form-modal.test.tsx create mode 100644 frontend/src/test/outbound-form-modal.test.tsx create mode 100644 frontend/src/test/setup.components.ts create mode 100644 frontend/src/test/test-utils.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 33634911..8543927b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -31,6 +31,8 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", "@types/swagger-ui-react": "^5.18.0", @@ -38,6 +40,7 @@ "eslint": "^10.4.0", "eslint-plugin-react-hooks": "^7.1.1", "globals": "^17.6.0", + "jsdom": "^29.1.1", "typescript": "^6.0.3", "typescript-eslint": "^8.60.0", "vite": "8.0.14", @@ -141,6 +144,57 @@ "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -402,6 +456,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@codemirror/autocomplete": { "version": "6.20.2", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.2.tgz", @@ -505,6 +572,146 @@ "w3c-keyname": "^2.2.4" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", + "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -679,6 +886,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -2629,6 +2854,54 @@ "react": "^18 || ^19" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -2640,6 +2913,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -3249,6 +3529,29 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/antd": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/antd/-/antd-6.4.3.tgz", @@ -3324,6 +3627,16 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3418,6 +3731,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", @@ -3715,6 +4038,20 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -3848,6 +4185,20 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/dayjs": { "version": "1.11.21", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", @@ -3871,6 +4222,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -3941,6 +4299,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3951,6 +4319,13 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dompurify": { "version": "3.4.7", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz", @@ -3990,6 +4365,19 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -4663,6 +5051,19 @@ "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", "license": "CC0-1.0" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -4881,6 +5282,13 @@ "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", "license": "MIT" }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-typed-array": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", @@ -4933,6 +5341,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5350,6 +5809,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -5369,6 +5838,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -5639,6 +6115,19 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5743,6 +6232,28 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -6165,6 +6676,16 @@ "node": ">=0.10" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -6240,6 +6761,19 @@ ], "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -6568,6 +7102,13 @@ "react-dom": ">=16.8.0 <20" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/throttle-debounce": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", @@ -6627,6 +7168,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz", + "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.2" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz", + "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-buffer": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", @@ -6647,6 +7208,32 @@ "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", "license": "MIT" }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tree-sitter": { "version": "0.21.1", "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.21.1.tgz", @@ -6796,6 +7383,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", + "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/unraw": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/unraw/-/unraw-3.0.0.tgz", @@ -7067,6 +7664,19 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-tree-sitter": { "version": "0.24.5", "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.24.5.tgz", @@ -7074,6 +7684,41 @@ "license": "MIT", "optional": true }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7153,6 +7798,23 @@ "repeat-string": "^1.5.2" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 66796ef9..aecf346a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -43,6 +43,8 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", "@types/swagger-ui-react": "^5.18.0", @@ -50,6 +52,7 @@ "eslint": "^10.4.0", "eslint-plugin-react-hooks": "^7.1.1", "globals": "^17.6.0", + "jsdom": "^29.1.1", "typescript": "^6.0.3", "typescript-eslint": "^8.60.0", "vite": "8.0.14", diff --git a/frontend/src/components/PromptModal.tsx b/frontend/src/components/feedback/PromptModal.tsx similarity index 100% rename from frontend/src/components/PromptModal.tsx rename to frontend/src/components/feedback/PromptModal.tsx diff --git a/frontend/src/components/TextModal.tsx b/frontend/src/components/feedback/TextModal.tsx similarity index 100% rename from frontend/src/components/TextModal.tsx rename to frontend/src/components/feedback/TextModal.tsx diff --git a/frontend/src/components/feedback/index.ts b/frontend/src/components/feedback/index.ts new file mode 100644 index 00000000..f24deac9 --- /dev/null +++ b/frontend/src/components/feedback/index.ts @@ -0,0 +1,2 @@ +export { default as PromptModal } from './PromptModal'; +export { default as TextModal } from './TextModal'; diff --git a/frontend/src/components/DateTimePicker.css b/frontend/src/components/form/DateTimePicker.css similarity index 100% rename from frontend/src/components/DateTimePicker.css rename to frontend/src/components/form/DateTimePicker.css diff --git a/frontend/src/components/DateTimePicker.tsx b/frontend/src/components/form/DateTimePicker.tsx similarity index 100% rename from frontend/src/components/DateTimePicker.tsx rename to frontend/src/components/form/DateTimePicker.tsx diff --git a/frontend/src/components/HeaderMapEditor.tsx b/frontend/src/components/form/HeaderMapEditor.tsx similarity index 98% rename from frontend/src/components/HeaderMapEditor.tsx rename to frontend/src/components/form/HeaderMapEditor.tsx index c630c851..86d769c4 100644 --- a/frontend/src/components/HeaderMapEditor.tsx +++ b/frontend/src/components/form/HeaderMapEditor.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { Button, Input, Space } from 'antd'; import { MinusOutlined, PlusOutlined } from '@ant-design/icons'; -import InputAddon from '@/components/InputAddon'; +import { InputAddon } from '@/components/ui'; // Reusable header-map editor. Handles the two wire shapes Xray uses for // HTTP-style header maps: diff --git a/frontend/src/components/JsonEditor.css b/frontend/src/components/form/JsonEditor.css similarity index 100% rename from frontend/src/components/JsonEditor.css rename to frontend/src/components/form/JsonEditor.css diff --git a/frontend/src/components/JsonEditor.tsx b/frontend/src/components/form/JsonEditor.tsx similarity index 100% rename from frontend/src/components/JsonEditor.tsx rename to frontend/src/components/form/JsonEditor.tsx diff --git a/frontend/src/components/form/index.ts b/frontend/src/components/form/index.ts new file mode 100644 index 00000000..9f3e3713 --- /dev/null +++ b/frontend/src/components/form/index.ts @@ -0,0 +1,3 @@ +export { default as DateTimePicker } from './DateTimePicker'; +export { default as JsonEditor } from './JsonEditor'; +export { default as HeaderMapEditor } from './HeaderMapEditor'; diff --git a/frontend/src/components/InfinityIcon.tsx b/frontend/src/components/ui/InfinityIcon.tsx similarity index 100% rename from frontend/src/components/InfinityIcon.tsx rename to frontend/src/components/ui/InfinityIcon.tsx diff --git a/frontend/src/components/InputAddon.css b/frontend/src/components/ui/InputAddon.css similarity index 100% rename from frontend/src/components/InputAddon.css rename to frontend/src/components/ui/InputAddon.css diff --git a/frontend/src/components/InputAddon.tsx b/frontend/src/components/ui/InputAddon.tsx similarity index 100% rename from frontend/src/components/InputAddon.tsx rename to frontend/src/components/ui/InputAddon.tsx diff --git a/frontend/src/components/SettingListItem.css b/frontend/src/components/ui/SettingListItem.css similarity index 100% rename from frontend/src/components/SettingListItem.css rename to frontend/src/components/ui/SettingListItem.css diff --git a/frontend/src/components/SettingListItem.tsx b/frontend/src/components/ui/SettingListItem.tsx similarity index 100% rename from frontend/src/components/SettingListItem.tsx rename to frontend/src/components/ui/SettingListItem.tsx diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts new file mode 100644 index 00000000..1e8121b5 --- /dev/null +++ b/frontend/src/components/ui/index.ts @@ -0,0 +1,3 @@ +export { default as InputAddon } from './InputAddon'; +export { default as InfinityIcon } from './InfinityIcon'; +export { default as SettingListItem } from './SettingListItem'; diff --git a/frontend/src/components/LazyMount.tsx b/frontend/src/components/utility/LazyMount.tsx similarity index 100% rename from frontend/src/components/LazyMount.tsx rename to frontend/src/components/utility/LazyMount.tsx diff --git a/frontend/src/components/utility/index.ts b/frontend/src/components/utility/index.ts new file mode 100644 index 00000000..6a3e0176 --- /dev/null +++ b/frontend/src/components/utility/index.ts @@ -0,0 +1 @@ +export { default as LazyMount } from './LazyMount'; diff --git a/frontend/src/components/Sparkline.css b/frontend/src/components/viz/Sparkline.css similarity index 100% rename from frontend/src/components/Sparkline.css rename to frontend/src/components/viz/Sparkline.css diff --git a/frontend/src/components/Sparkline.tsx b/frontend/src/components/viz/Sparkline.tsx similarity index 100% rename from frontend/src/components/Sparkline.tsx rename to frontend/src/components/viz/Sparkline.tsx diff --git a/frontend/src/components/viz/index.ts b/frontend/src/components/viz/index.ts new file mode 100644 index 00000000..1b402935 --- /dev/null +++ b/frontend/src/components/viz/index.ts @@ -0,0 +1 @@ +export { default as Sparkline } from './Sparkline'; diff --git a/frontend/src/components/AppSidebar.css b/frontend/src/layouts/AppSidebar.css similarity index 100% rename from frontend/src/components/AppSidebar.css rename to frontend/src/layouts/AppSidebar.css diff --git a/frontend/src/components/AppSidebar.tsx b/frontend/src/layouts/AppSidebar.tsx similarity index 100% rename from frontend/src/components/AppSidebar.tsx rename to frontend/src/layouts/AppSidebar.tsx diff --git a/frontend/src/components/FinalMaskForm.tsx b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx similarity index 100% rename from frontend/src/components/FinalMaskForm.tsx rename to frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx diff --git a/frontend/src/lib/xray/forms/transport/index.ts b/frontend/src/lib/xray/forms/transport/index.ts new file mode 100644 index 00000000..d2ab3e39 --- /dev/null +++ b/frontend/src/lib/xray/forms/transport/index.ts @@ -0,0 +1 @@ +export { default as FinalMaskForm } from './FinalMaskForm'; diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index a30cf81f..314c125b 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -2,7 +2,7 @@ import { Base64, Wireguard } from '@/utils'; import type { Inbound } from '@/schemas/api/inbound'; import type { VlessClient } from '@/schemas/protocols/inbound/vless'; -import type { VmessSecurity } from '@/schemas/protocols/inbound/vmess'; +import type { VmessSecurity } from '@/schemas/protocols/shared/vmess'; import type { WireguardInboundPeer, WireguardInboundSettings, diff --git a/frontend/src/pages/api-docs/ApiDocsPage.tsx b/frontend/src/pages/api-docs/ApiDocsPage.tsx index 8841c707..d2e90c0e 100644 --- a/frontend/src/pages/api-docs/ApiDocsPage.tsx +++ b/frontend/src/pages/api-docs/ApiDocsPage.tsx @@ -4,7 +4,7 @@ import SwaggerUI from 'swagger-ui-react'; import 'swagger-ui-react/swagger-ui.css'; import { useTheme } from '@/hooks/useTheme'; -import AppSidebar from '@/components/AppSidebar'; +import AppSidebar from '@/layouts/AppSidebar'; import './ApiDocsPage.css'; const basePath = window.X_UI_BASE_PATH || ''; diff --git a/frontend/src/pages/clients/ClientBulkAddModal.tsx b/frontend/src/pages/clients/ClientBulkAddModal.tsx index d86e44a9..a317119a 100644 --- a/frontend/src/pages/clients/ClientBulkAddModal.tsx +++ b/frontend/src/pages/clients/ClientBulkAddModal.tsx @@ -7,7 +7,7 @@ import type { Dayjs } from 'dayjs'; import { RandomUtil, SizeFormatter } from '@/utils'; import { TLS_FLOW_CONTROL } from '@/schemas/primitives'; -import DateTimePicker from '@/components/DateTimePicker'; +import { DateTimePicker } from '@/components/form'; import { useClients, type InboundOption } from '@/hooks/useClients'; import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas/client'; diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index 4f1f575e..cc953285 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/frontend/src/pages/clients/ClientFormModal.tsx @@ -20,7 +20,7 @@ import dayjs from 'dayjs'; import type { Dayjs } from 'dayjs'; import { HttpUtil, RandomUtil } from '@/utils'; -import DateTimePicker from '@/components/DateTimePicker'; +import { DateTimePicker } from '@/components/form'; import { TLS_FLOW_CONTROL } from '@/schemas/primitives'; import type { ClientRecord, InboundOption } from '@/hooks/useClients'; import { ClientFormSchema, ClientCreateFormSchema } from '@/schemas/client'; diff --git a/frontend/src/pages/clients/ClientInfoModal.tsx b/frontend/src/pages/clients/ClientInfoModal.tsx index f422064e..fd4cb7d8 100644 --- a/frontend/src/pages/clients/ClientInfoModal.tsx +++ b/frontend/src/pages/clients/ClientInfoModal.tsx @@ -7,7 +7,7 @@ import { ClipboardManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils'; import { useDatepicker } from '@/hooks/useDatepicker'; import type { ClientRecord, InboundOption } from '@/hooks/useClients'; import { isPostQuantumLink } from '@/lib/xray/inbound-link'; -import QrPanel from '@/pages/inbounds/QrPanel'; +import { QrPanel } from '@/pages/inbounds/qr'; import './ClientInfoModal.css'; const PROTOCOL_COLORS: Record = { diff --git a/frontend/src/pages/clients/ClientQrModal.tsx b/frontend/src/pages/clients/ClientQrModal.tsx index 0cd9e56c..da6b11e1 100644 --- a/frontend/src/pages/clients/ClientQrModal.tsx +++ b/frontend/src/pages/clients/ClientQrModal.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { Collapse, Modal, Spin } from 'antd'; import { HttpUtil } from '@/utils'; import { isPostQuantumLink } from '@/lib/xray/inbound-link'; -import QrPanel from '@/pages/inbounds/QrPanel'; +import { QrPanel } from '@/pages/inbounds/qr'; import type { ClientRecord } from '@/hooks/useClients'; interface SubSettings { diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index fc476973..44ca73e7 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/frontend/src/pages/clients/ClientsPage.tsx @@ -51,10 +51,10 @@ import { useWebSocket } from '@/hooks/useWebSocket'; import { useClients } from '@/hooks/useClients'; import { useDatepicker } from '@/hooks/useDatepicker'; import type { ClientRecord, InboundOption } from '@/hooks/useClients'; -import AppSidebar from '@/components/AppSidebar'; +import AppSidebar from '@/layouts/AppSidebar'; import { IntlUtil, SizeFormatter } from '@/utils'; import { setMessageInstance } from '@/utils/messageBus'; -import LazyMount from '@/components/LazyMount'; +import { LazyMount } from '@/components/utility'; const ClientFormModal = lazy(() => import('./ClientFormModal')); const ClientInfoModal = lazy(() => import('./ClientInfoModal')); const ClientQrModal = lazy(() => import('./ClientQrModal')); diff --git a/frontend/src/pages/groups/GroupsPage.tsx b/frontend/src/pages/groups/GroupsPage.tsx index 12690c0f..4b1882cd 100644 --- a/frontend/src/pages/groups/GroupsPage.tsx +++ b/frontend/src/pages/groups/GroupsPage.tsx @@ -42,8 +42,8 @@ import { usePageTitle } from '@/hooks/usePageTitle'; import { useClients } from '@/hooks/useClients'; import { HttpUtil } from '@/utils'; import { setMessageInstance } from '@/utils/messageBus'; -import AppSidebar from '@/components/AppSidebar'; -import LazyMount from '@/components/LazyMount'; +import AppSidebar from '@/layouts/AppSidebar'; +import { LazyMount } from '@/components/utility'; import { keys } from '@/api/queryKeys'; import { ClientRecordSchema, diff --git a/frontend/src/pages/inbounds/InboundFormModal.tsx b/frontend/src/pages/inbounds/InboundFormModal.tsx deleted file mode 100644 index 609717bf..00000000 --- a/frontend/src/pages/inbounds/InboundFormModal.tsx +++ /dev/null @@ -1,3129 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import dayjs from 'dayjs'; -import { - Button, - Card, - Checkbox, - Divider, - Empty, - Form, - Input, - InputNumber, - Modal, - Radio, - Select, - Space, - Switch, - Tabs, - Tooltip, - Typography, - message, -} from 'antd'; -import { - ArrowDownOutlined, - ArrowUpOutlined, - DeleteOutlined, - MinusOutlined, - PlusOutlined, - ReloadOutlined, -} from '@ant-design/icons'; - -import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils'; -import { - rawInboundToFormValues, - formValuesToWirePayload, - pruneEmpty, - normalizeSniffing, - normalizeClients, - dropLegacyOptionalEmpties, -} from '@/lib/xray/inbound-form-adapter'; -import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults'; -import { - canEnableReality, - canEnableStream, - canEnableTls, - isSS2022, -} from '@/lib/xray/protocol-capabilities'; -import { SSMethodSchema } from '@/schemas/protocols/inbound/shadowsocks'; -import { getRandomRealityTarget } from '@/models/reality-targets'; -import { - InboundFormBaseSchema, - InboundFormSchema, - type FallbackRow, - type InboundFormValues, -} from '@/schemas/forms/inbound-form'; -import { antdRule } from '@/utils/zodForm'; -import { - ALPN_OPTION, - Address_Port_Strategy, - DOMAIN_STRATEGY_OPTION, - Protocols, - SNIFFING_OPTION, - TCP_CONGESTION_OPTION, - TLS_CIPHER_OPTION, - TLS_VERSION_OPTION, - USAGE_OPTION, - UTLS_FINGERPRINT, -} from '@/schemas/primitives'; -import { - HappyEyeballsSchema, - SockoptStreamSettingsSchema, -} from '@/schemas/protocols/stream/sockopt'; -import { HysteriaStreamSettingsSchema } from '@/schemas/protocols/stream/hysteria'; -import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls'; -import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality'; -import { SniffingSchema } from '@/schemas/primitives/sniffing'; -import { TcpStreamSettingsSchema } from '@/schemas/protocols/stream/tcp'; -import { KcpStreamSettingsSchema } from '@/schemas/protocols/stream/kcp'; -import { WsStreamSettingsSchema } from '@/schemas/protocols/stream/ws'; -import { GrpcStreamSettingsSchema } from '@/schemas/protocols/stream/grpc'; -import { HttpUpgradeStreamSettingsSchema } from '@/schemas/protocols/stream/httpupgrade'; -import { XHttpStreamSettingsSchema } from '@/schemas/protocols/stream/xhttp'; -import DateTimePicker from '@/components/DateTimePicker'; -import FinalMaskForm from '@/components/FinalMaskForm'; -import HeaderMapEditor from '@/components/HeaderMapEditor'; -import HysteriaMasqueradeForm from '@/components/HysteriaMasqueradeForm'; -import InputAddon from '@/components/InputAddon'; -import JsonEditor from '@/components/JsonEditor'; -import './InboundFormModal.css'; -import type { FormInstance } from 'antd'; -import type { NamePath } from 'antd/es/form/interface'; - -const { TextArea } = Input; -import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound'; -import type { NodeRecord } from '@/api/queries/useNodesQuery'; - -// Pattern A rewrite of InboundFormModal. Built as a sibling file so the -// build stays green while the rewrite progresses section by section. -// InboundsPage continues to render the old InboundFormModal.tsx until the -// atomic swap at the end (Core Decision 7). - -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; -const NODE_ELIGIBLE_PROTOCOLS = new Set([ - Protocols.VLESS, - Protocols.VMESS, - Protocols.TROJAN, - Protocols.SHADOWSOCKS, - Protocols.HYSTERIA, - Protocols.WIREGUARD, -]); - -interface InboundFormModalProps { - open: boolean; - onClose: () => void; - onSaved: () => void; - mode: 'add' | 'edit'; - dbInbound: DBInbound | null; - dbInbounds: DBInbound[]; - availableNodes?: NodeRecord[]; -} - -function buildAddModeValues(): InboundFormValues { - const settings = createDefaultInboundSettings('vless') ?? undefined; - return rawInboundToFormValues({ - protocol: 'vless', - settings, - streamSettings: { - network: 'tcp', - security: 'none', - tcpSettings: TcpStreamSettingsSchema.parse({ header: { type: 'none' } }), - }, - sniffing: SniffingSchema.parse({}), - port: RandomUtil.randomInteger(10000, 60000), - listen: '', - tag: '', - enable: true, - trafficReset: 'never', - }); -} - -export default function InboundFormModal({ - open, - onClose, - onSaved, - mode, - dbInbound, - dbInbounds, - availableNodes, -}: InboundFormModalProps) { - const { t } = useTranslation(); - const [messageApi, messageContextHolder] = message.useMessage(); - const [form] = Form.useForm(); - const [saving, setSaving] = useState(false); - const fallbackKeyRef = useRef(0); - const [fallbacks, setFallbacks] = useState([]); - - const selectableNodes = (availableNodes || []).filter((n) => n.enable); - const protocol = (Form.useWatch('protocol', form) ?? '') as string; - const isNodeEligible = NODE_ELIGIBLE_PROTOCOLS.has(protocol); - const sniffingEnabled = Form.useWatch(['sniffing', 'enabled'], form) ?? false; - const vlessEncryption = Form.useWatch(['settings', 'encryption'], form) ?? ''; - const ssMethod = Form.useWatch(['settings', 'method'], form); - const isSSWith2022 = isSS2022({ - protocol, - settings: typeof ssMethod === 'string' ? { method: ssMethod } : {}, - }); - const mixedUdpOn = Form.useWatch(['settings', 'udp'], form) ?? false; - const network = Form.useWatch(['streamSettings', 'network'], form) ?? ''; - const security = Form.useWatch(['streamSettings', 'security'], form) ?? 'none'; - const streamEnabled = canEnableStream({ protocol }); - const isFallbackHost = - (protocol === Protocols.VLESS || protocol === Protocols.TROJAN) - && network === 'tcp' - && (security === 'tls' || security === 'reality'); - - const fallbackChildOptions = (dbInbounds || []) - .filter((ib) => ib.id !== dbInbound?.id) - .map((ib) => ({ - label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`, - value: ib.id, - })); - - const loadFallbacks = async (masterId: number | null) => { - if (!masterId) { - setFallbacks([]); - return; - } - const msg = await HttpUtil.get(`/panel/api/inbounds/${masterId}/fallbacks`); - if (!msg?.success || !Array.isArray(msg.obj)) { - setFallbacks([]); - return; - } - setFallbacks( - (msg.obj as { - childId: number; - name?: string; - alpn?: string; - path?: string; - dest?: string; - xver?: number; - }[]) - .map((r) => ({ - rowKey: `fb-${++fallbackKeyRef.current}`, - childId: r.childId, - name: r.name || '', - alpn: r.alpn || '', - path: r.path || '', - dest: r.dest || '', - xver: r.xver || 0, - })), - ); - }; - - const saveFallbacks = async (masterId: number) => { - if (!masterId) return true; - const payload = { - fallbacks: fallbacks.filter((c) => c.childId).map((c, i) => ({ - childId: c.childId, - name: c.name, - alpn: c.alpn, - path: c.path, - dest: c.dest, - xver: Number(c.xver) || 0, - sortOrder: i, - })), - }; - const msg = await HttpUtil.post( - `/panel/api/inbounds/${masterId}/fallbacks`, - payload, - { headers: { 'Content-Type': 'application/json' } }, - ); - return !!msg?.success; - }; - - // Derive a fallback row's SNI / ALPN / Path / xver from a child - // inbound's streamSettings — what the legacy panel auto-filled when an - // operator wired a fallback target. SNI/ALPN come straight off the - // child's TLS block; path depends on the child's transport (ws/grpc - // /httpupgrade carry an explicit path; tcp/kcp/xhttp have no path of - // their own). xver stays 0 unless the child explicitly opts in via - // PROXY-protocol sockopt. - const deriveFallbackDefaults = (childId: number): Partial => { - const child = (dbInbounds || []).find((ib) => ib.id === childId); - if (!child) return {}; - const stream = coerceInboundJsonField(child.streamSettings); - const tls = (stream.tlsSettings as Record | undefined) ?? {}; - const network = typeof stream.network === 'string' ? stream.network : ''; - const sni = typeof tls.serverName === 'string' ? tls.serverName : ''; - const alpnArr = Array.isArray(tls.alpn) ? tls.alpn : []; - const alpn = alpnArr.filter((v) => typeof v === 'string').join(','); - let path = ''; - if (network === 'ws') { - const ws = (stream.wsSettings as Record | undefined) ?? {}; - if (typeof ws.path === 'string') path = ws.path; - } else if (network === 'grpc') { - const grpc = (stream.grpcSettings as Record | undefined) ?? {}; - if (typeof grpc.serviceName === 'string') path = grpc.serviceName; - } else if (network === 'httpupgrade') { - const hu = (stream.httpupgradeSettings as Record | undefined) ?? {}; - if (typeof hu.path === 'string') path = hu.path; - } else if (network === 'xhttp') { - const xh = (stream.xhttpSettings as Record | undefined) ?? {}; - if (typeof xh.path === 'string') path = xh.path; - } - return { name: sni, alpn, path, xver: 0 }; - }; - - const addFallback = () => { - setFallbacks((prev) => [...prev, { - rowKey: `fb-${++fallbackKeyRef.current}`, - childId: null, - name: '', - alpn: '', - path: '', - dest: '', - xver: 0, - }]); - }; - - const updateFallback = (rowKey: string, patch: Partial) => { - setFallbacks((prev) => prev.map((r) => { - if (r.rowKey !== rowKey) return r; - // When the picker selects a new child inbound and the row hasn't - // been hand-edited yet (sni/alpn/path/dest all blank, xver = 0), - // pull the SNI/ALPN/Path defaults off that child. Operators who - // intentionally typed values keep them — we only fill the empties. - if (typeof patch.childId === 'number' && patch.childId !== r.childId) { - const isPristine = !r.name && !r.alpn && !r.path && !r.dest && r.xver === 0; - if (isPristine) return { ...r, ...patch, ...deriveFallbackDefaults(patch.childId) }; - } - return { ...r, ...patch }; - })); - }; - - const removeFallback = (idx: number) => { - setFallbacks((prev) => prev.filter((_, i) => i !== idx)); - }; - - // Move a fallback row up/down by swapping adjacent indices. The order - // is persisted via the fallback row's sortOrder (rebuilt by index on - // save), so reordering survives reloads. - const moveFallback = (idx: number, direction: -1 | 1) => { - setFallbacks((prev) => { - const target = idx + direction; - if (target < 0 || target >= prev.length) return prev; - const next = prev.slice(); - [next[idx], next[target]] = [next[target], next[idx]]; - return next; - }); - }; - - // One-shot: add a fresh fallback row for every eligible inbound (i.e. - // every option in fallbackChildOptions) that is not already wired up. - // Convenient for operators who want catch-all routing to every host - // they manage on the panel. - const addAllFallbacks = () => { - setFallbacks((prev) => { - const alreadyHave = new Set(prev.map((r) => r.childId)); - const additions = fallbackChildOptions - .filter((opt) => !alreadyHave.has(opt.value)) - .map((opt) => { - const derived = deriveFallbackDefaults(opt.value); - return { - rowKey: `fb-${++fallbackKeyRef.current}`, - childId: opt.value, - name: derived.name ?? '', - alpn: derived.alpn ?? '', - path: derived.path ?? '', - dest: '', - xver: derived.xver ?? 0, - }; - }); - if (additions.length === 0) return prev; - return [...prev, ...additions]; - }); - }; - - const genRealityKeypair = async () => { - setSaving(true); - try { - const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert'); - if (msg?.success) { - const obj = msg.obj as { privateKey: string; publicKey: string }; - form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], obj.privateKey); - form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'publicKey'], obj.publicKey); - } - } finally { - setSaving(false); - } - }; - - const clearRealityKeypair = () => { - form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], ''); - form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'publicKey'], ''); - }; - - const genMldsa65 = async () => { - setSaving(true); - try { - const msg = await HttpUtil.get('/panel/api/server/getNewmldsa65'); - if (msg?.success) { - const obj = msg.obj as { seed: string; verify: string }; - form.setFieldValue(['streamSettings', 'realitySettings', 'mldsa65Seed'], obj.seed); - form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify'], obj.verify); - } - } finally { - setSaving(false); - } - }; - - const clearMldsa65 = () => { - form.setFieldValue(['streamSettings', 'realitySettings', 'mldsa65Seed'], ''); - form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify'], ''); - }; - - const randomizeRealityTarget = () => { - const tgt = getRandomRealityTarget() as { target: string; sni: string }; - form.setFieldValue(['streamSettings', 'realitySettings', 'target'], tgt.target); - form.setFieldValue( - ['streamSettings', 'realitySettings', 'serverNames'], - tgt.sni.split(',').map((s) => s.trim()).filter(Boolean), - ); - }; - - const randomizeShortIds = () => { - form.setFieldValue( - ['streamSettings', 'realitySettings', 'shortIds'], - RandomUtil.randomShortIds().split(',').map((s) => s.trim()).filter(Boolean), - ); - }; - - const getNewEchCert = async () => { - const sni = form.getFieldValue(['streamSettings', 'tlsSettings', 'serverName']); - setSaving(true); - try { - const msg = await HttpUtil.post('/panel/api/server/getNewEchCert', { sni }); - if (msg?.success) { - const obj = msg.obj as { echServerKeys: string; echConfigList: string }; - form.setFieldValue(['streamSettings', 'tlsSettings', 'echServerKeys'], obj.echServerKeys); - form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'echConfigList'], obj.echConfigList); - } - } finally { - setSaving(false); - } - }; - - const clearEchCert = () => { - form.setFieldValue(['streamSettings', 'tlsSettings', 'echServerKeys'], ''); - form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'echConfigList'], ''); - }; - - const generateRandomPinHash = () => { - const bytes = new Uint8Array(32); - crypto.getRandomValues(bytes); - let binary = ''; - for (const b of bytes) binary += String.fromCharCode(b); - const hash = btoa(binary); - const current = (form.getFieldValue( - ['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'], - ) as string[] | undefined) ?? []; - form.setFieldValue( - ['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'], - [...current, hash], - ); - }; - - const setCertFromPanel = async (certName: number) => { - setSaving(true); - try { - const msg = await HttpUtil.post('/panel/setting/all', undefined, { silent: true }); - if (msg?.success) { - const obj = msg.obj as { webCertFile?: string; webKeyFile?: string }; - if (!obj.webCertFile && !obj.webKeyFile) { - messageApi.warning(t('pages.inbounds.setDefaultCertEmpty')); - return; - } - form.setFieldValue( - ['streamSettings', 'tlsSettings', 'certificates', certName, 'certificateFile'], - obj.webCertFile ?? '', - ); - form.setFieldValue( - ['streamSettings', 'tlsSettings', 'certificates', certName, 'keyFile'], - obj.webKeyFile ?? '', - ); - } - } finally { - setSaving(false); - } - }; - - const clearCertFiles = (certName: number) => { - form.setFieldValue( - ['streamSettings', 'tlsSettings', 'certificates', certName, 'certificateFile'], - '', - ); - form.setFieldValue( - ['streamSettings', 'tlsSettings', 'certificates', certName, 'keyFile'], - '', - ); - }; - - const onSecurityChange = async (next: string) => { - const current = (form.getFieldValue('streamSettings') as Record) ?? {}; - const cleaned: Record = { ...current, security: next }; - delete cleaned.tlsSettings; - delete cleaned.realitySettings; - if (next === 'tls') { - const tls = TlsStreamSettingsSchema.parse({}) as Record; - tls.certificates = [{ - useFile: true, - certificateFile: '', - keyFile: '', - certificate: [], - key: [], - oneTimeLoading: false, - usage: 'encipherment', - buildChain: false, - }]; - cleaned.tlsSettings = tls; - } - if (next === 'reality') { - const reality = RealityStreamSettingsSchema.parse({}) as Record; - const tgt = getRandomRealityTarget() as { target: string; sni: string }; - reality.target = tgt.target; - reality.serverNames = tgt.sni.split(',').map((s) => s.trim()).filter(Boolean); - reality.shortIds = RandomUtil.randomShortIds().split(',').map((s) => s.trim()).filter(Boolean); - cleaned.realitySettings = reality; - } - form.setFieldValue('streamSettings', cleaned); - if (next === 'reality') { - try { - const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert'); - if (msg?.success) { - const obj = msg.obj as { privateKey: string; publicKey: string }; - form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], obj.privateKey); - form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'publicKey'], obj.publicKey); - } - } catch { - // best-effort: leave keypair fields empty if server call fails - } - } - }; - const xhttpMode = Form.useWatch(['streamSettings', 'xhttpSettings', 'mode'], form); - const xhttpObfsMode = Form.useWatch(['streamSettings', 'xhttpSettings', 'xPaddingObfsMode'], form) ?? false; - 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 toggleExternalProxy = (on: boolean) => { - if (on) { - const port = (form.getFieldValue('port') as number) ?? 443; - form.setFieldValue(['streamSettings', 'externalProxy'], [{ - forceTls: 'same', - dest: typeof window !== 'undefined' ? window.location.hostname : '', - port, - remark: '', - sni: '', - fingerprint: '', - alpn: [], - }]); - } else { - form.setFieldValue(['streamSettings', 'externalProxy'], []); - } - }; - - const toggleSockopt = (on: boolean) => { - if (on) { - form.setFieldValue( - ['streamSettings', 'sockopt'], - SockoptStreamSettingsSchema.parse({}), - ); - } else { - form.setFieldValue(['streamSettings', 'sockopt'], undefined); - } - }; - const wgSecretKey = Form.useWatch(['settings', 'secretKey'], form); - const wgPubKey = typeof wgSecretKey === 'string' && wgSecretKey.length > 0 - ? Wireguard.generateKeypair(wgSecretKey).publicKey - : ''; - - const regenInboundWg = () => { - const kp = Wireguard.generateKeypair(); - form.setFieldValue(['settings', 'secretKey'], kp.privateKey); - }; - - const regenWgPeerKeypair = (peerName: number) => { - const kp = Wireguard.generateKeypair(); - form.setFieldValue(['settings', 'peers', peerName, 'privateKey'], kp.privateKey); - form.setFieldValue(['settings', 'peers', peerName, 'publicKey'], kp.publicKey); - }; - - const matchesVlessAuth = ( - block: { id?: string; label?: string } | undefined | null, - authId: string, - ) => { - if (block?.id === authId) return true; - const label = (block?.label || '').toLowerCase().replace(/[-_\s]/g, ''); - if (authId === 'mlkem768') return label.includes('mlkem768'); - if (authId === 'x25519') return label.includes('x25519'); - return false; - }; - - const getNewVlessEnc = async (authId: string) => { - if (!authId) return; - setSaving(true); - try { - const msg = await HttpUtil.get('/panel/api/server/getNewVlessEnc'); - if (!msg?.success) return; - const obj = msg.obj as { - auths?: { decryption: string; encryption: string; label?: string; id?: string }[]; - }; - const block = (obj.auths || []).find((a) => matchesVlessAuth(a, authId)); - if (!block) return; - form.setFieldValue(['settings', 'decryption'], block.decryption); - form.setFieldValue(['settings', 'encryption'], block.encryption); - } finally { - setSaving(false); - } - }; - - const clearVlessEnc = () => { - form.setFieldValue(['settings', 'decryption'], 'none'); - form.setFieldValue(['settings', 'encryption'], 'none'); - }; - - const selectedVlessAuth = (() => { - const enc = typeof vlessEncryption === 'string' ? vlessEncryption : ''; - if (!enc || enc === 'none') return 'None'; - const parts = enc.split('.').filter(Boolean); - const authKey = parts[parts.length - 1] || ''; - if (!authKey) return t('pages.inbounds.vlessAuthCustom'); - return authKey.length > 300 - ? t('pages.inbounds.vlessAuthMlkem768') - : t('pages.inbounds.vlessAuthX25519'); - })(); - - useEffect(() => { - if (!open) return; - const initial = mode === 'edit' && dbInbound - ? rawInboundToFormValues(dbInbound) - : buildAddModeValues(); - form.resetFields(); - form.setFieldsValue(initial); - if ( - mode === 'edit' - && dbInbound - && (dbInbound.protocol === Protocols.VLESS || dbInbound.protocol === Protocols.TROJAN) - ) { - loadFallbacks(dbInbound.id); - } else { - setFallbacks([]); - } - - }, [open, mode, dbInbound, form]); - - // Why: protocol picker reset cascades through the form — clearing the - // settings DU branch and dropping a nodeId that no longer applies. The - // legacy modal did this imperatively in onProtocolChange; here we hook - // into AntD's onValuesChange and let setFieldValue keep the rest of - // the form state intact. - const onValuesChange = (changed: Partial) => { - if (mode === 'edit') return; - if ('protocol' in changed && typeof changed.protocol === 'string') { - const next = changed.protocol; - const settings = createDefaultInboundSettings(next) ?? undefined; - form.setFieldValue('settings', settings); - if (!NODE_ELIGIBLE_PROTOCOLS.has(next)) { - form.setFieldValue('nodeId', null); - } - // Hysteria uses its dedicated transport — force the network branch - // so the stream tab renders the hysteria sub-form, not the leftover - // tcpSettings from the previous protocol. When leaving hysteria, - // snap back to TCP so the standard network selector has a valid - // starting point. - if (next === Protocols.HYSTERIA) { - const tls = TlsStreamSettingsSchema.parse({}) as Record; - tls.certificates = [{ - useFile: true, - certificateFile: '', - keyFile: '', - certificate: [], - key: [], - oneTimeLoading: false, - usage: 'encipherment', - buildChain: false, - }]; - form.setFieldValue('streamSettings', { - network: 'hysteria', - security: 'tls', - hysteriaSettings: HysteriaStreamSettingsSchema.parse({}), - tlsSettings: tls, - // Hysteria2 needs an obfs wrapper on the FinalMask side; seed - // it with salamander + a random password so the listener boots - // with a usable default. Re-selecting Hysteria from another - // protocol re-runs this and refreshes the password — that's - // intentional, the form was already being reset. - finalmask: { - tcp: [], - udp: [{ - type: 'salamander', - settings: { password: RandomUtil.randomLowerAndNum(16) }, - }], - }, - }); - } else { - const current = form.getFieldValue('streamSettings') as { network?: string } | undefined; - if (current?.network === 'hysteria') { - form.setFieldValue('streamSettings', { network: 'tcp', security: 'none', tcpSettings: {} }); - } - } - } - }; - - const submit = async () => { - try { - await form.validateFields(); - } catch { - return; - } - // Why getFieldsValue(true) instead of the validateFields return value: - // rc-component/form's validateFields filters its output by REGISTERED - // name paths. settings.clients and settings.fallbacks have no Form.Item - // bound to them (clients are managed via the standalone Client modal, - // not inside this inbound modal) — so validateFields would drop them - // and the update wire payload would silently delete every client on - // every save. getFieldsValue(true) returns the entire form store and - // keeps those sub-trees intact. - const values = form.getFieldsValue(true) as InboundFormValues; - const parsed = InboundFormSchema.safeParse(values); - if (!parsed.success) { - const issue = parsed.error.issues[0]; - const path = Array.isArray(issue?.path) && issue.path.length > 0 - ? issue.path.join('.') - : ''; - const baseMsg = issue?.message ?? 'somethingWentWrong'; - const display = path ? `${path}: ${baseMsg}` : baseMsg; - messageApi.error(t(baseMsg, { defaultValue: display })); - console.error('[InboundFormModal] schema validation failed', { - path: issue?.path, - message: issue?.message, - values, - }); - return; - } - setSaving(true); - try { - const payload = formValuesToWirePayload(parsed.data); - const url = mode === 'edit' && dbInbound - ? `/panel/api/inbounds/update/${dbInbound.id}` - : '/panel/api/inbounds/add'; - const msg = await HttpUtil.post(url, payload); - if (msg?.success) { - if (isFallbackHost) { - const obj = msg.obj as { id?: number; Id?: number } | null; - const masterId = mode === 'edit' - ? dbInbound!.id - : (obj?.id ?? obj?.Id ?? 0); - if (masterId) await saveFallbacks(masterId); - } - onSaved(); - onClose(); - } - } finally { - setSaving(false); - } - }; - - const title = mode === 'edit' - ? t('pages.inbounds.modifyInbound') - : t('pages.inbounds.addInbound'); - - const okText = mode === 'edit' - ? t('pages.clients.submitEdit') - : t('create'); - - const basicTab = ( - <> - - - - - - - - - - - - - - - - - {selectableNodes.length > 0 && isNodeEligible && ( - - - - - - - - - - - - - - {t('pages.inbounds.totalFlow')} - - } - > - prev.total !== curr.total} - > - {({ getFieldValue, setFieldValue }) => { - const totalBytes = (getFieldValue('total') as number) ?? 0; - const totalGB = totalBytes - ? Math.round((totalBytes / SizeFormatter.ONE_GB) * 100) / 100 - : 0; - return ( - { - const bytes = NumberFormatter.toFixed( - (Number(v) || 0) * SizeFormatter.ONE_GB, - 0, - ); - setFieldValue('total', bytes); - }} - /> - ); - }} - - - - - - ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()), - }} - style={{ width: '100%' }} - onChange={(v) => updateFallback(record.rowKey, { childId: v })} - /> - - - - - - SNI - updateFallback(record.rowKey, { name: e.target.value })} - /> - ALPN - updateFallback(record.rowKey, { alpn: e.target.value })} - /> - Path - updateFallback(record.rowKey, { path: e.target.value })} - /> - Dest - updateFallback(record.rowKey, { dest: e.target.value })} - /> - xver - updateFallback(record.rowKey, { xver: Number(v) || 0 })} - /> - - - ))} - - - - - - ); - - const protocolTab = ( - <> - {protocol === Protocols.WIREGUARD && ( - <> - - - - - - - - {fields.map((field, idx) => ( -
- - - {t('pages.inbounds.info.peerNumber', { n: idx + 1 })} - {fields.length > 1 && ( - - {ipFields.map((ipField) => ( - - - - - {ipFields.length > 1 && ( - - )} - - ))} - - )} - - - - -
- ))} - - )} - - - )} - - {protocol === Protocols.TUN && ( - <> - - - - - - - - {(fields, { add, remove }) => ( - - - {fields.map((field, j) => ( - - - - - - - ))} - - )} - - - {(fields, { add, remove }) => ( - - - {fields.map((field, j) => ( - - - - - - - ))} - - )} - - - - - - {(fields, { add, remove }) => ( - - {t('pages.inbounds.info.autoSystemRoutes')} - - } - > - - {fields.map((field, j) => ( - - - - - - - ))} - - )} - - - {t('pages.inbounds.form.autoOutboundsInterface')} - - } - > - - - - )} - - {protocol === Protocols.TUNNEL && ( - <> - - - - - - - - - - - - - - - ))} -
- )} - - )} - - {protocol === Protocols.HTTP && ( - - - - )} - {protocol === Protocols.MIXED && ( - <> - - - - )} - - )} - - )} - - {protocol === Protocols.SHADOWSOCKS && ( - <> - - - - - - - - - {t('pages.inbounds.vlessAuthSelected', { auth: selectedVlessAuth })} - - - {network === 'tcp' && (security === 'tls' || security === 'reality') && ( - - - {[900, 500, 900, 256].map((def, i) => ( - - - - ))} - - - )} - - )} - - {isFallbackHost && fallbacksCard} - - ); - - // Switching `network` swaps which per-network key (tcpSettings, - // wsSettings, grpcSettings, ...) appears on the wire. Clear the old - // network's blob and seed the new one with the schema defaults so the - // Form.Items inside it have valid initial values (KCP needs MTU=1350 - // etc., not empty strings). - // Seed each network's settings blob with its Zod schema defaults so - // every Form.Item inside the network sub-form has a defined starting - // value. XHTTP in particular has ~20 fields (sessionPlacement, - // seqPlacement, xPaddingMethod, uplinkHTTPMethod, ...) whose value - // is the literal "" sentinel meaning "let xray-core pick its - // default". Without seeding "", the Form.Item reads `undefined` and - // the Select shows blank instead of the "Default (path)" option. - const newStreamSlice = (n: string): Record => { - switch (n) { - case 'tcp': return TcpStreamSettingsSchema.parse({ header: { type: 'none' } }); - case 'kcp': return KcpStreamSettingsSchema.parse({}); - case 'ws': return WsStreamSettingsSchema.parse({}); - case 'grpc': return GrpcStreamSettingsSchema.parse({}); - case 'httpupgrade': return HttpUpgradeStreamSettingsSchema.parse({}); - case 'xhttp': return XHttpStreamSettingsSchema.parse({}); - default: return {}; - } - }; - const onNetworkChange = (next: string) => { - const ALL = ['tcpSettings', 'kcpSettings', 'wsSettings', 'grpcSettings', 'httpupgradeSettings', 'xhttpSettings']; - const current = (form.getFieldValue('streamSettings') as Record) ?? {}; - const cleaned: Record = { ...current, network: next }; - for (const k of ALL) { - if (k !== `${next}Settings`) delete cleaned[k]; - } - cleaned[`${next}Settings`] = newStreamSlice(next); - // mKCP wants a UDP mask wrapper on the FinalMask side; seed it with - // `mkcp-original` so the inbound boots with a sensible default - // instead of unobfuscated mKCP traffic. The user can still edit or - // clear the mask via the FinalMask section. - if (next === 'kcp') { - const fm = (cleaned.finalmask as Record | undefined) ?? {}; - const udp = Array.isArray(fm.udp) ? (fm.udp as unknown[]) : []; - const hasMkcp = udp.some((m) => { - const entry = m as { type?: string }; - return entry?.type === 'mkcp-original'; - }); - if (!hasMkcp) { - cleaned.finalmask = { - ...fm, - udp: [...udp, { type: 'mkcp-original', settings: {} }], - }; - } - } - form.setFieldValue('streamSettings', cleaned); - }; - - const streamTab = ( - <> - {protocol !== Protocols.HYSTERIA && ( - - - - - - - ({ 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 : ['/']; - }} - > - - - - - - - - - - - - - - - - - - - ); - }} - - - )} - - {network === 'ws' && ( - <> - - - - - - - - - - - - - - - - - )} - - {network === 'grpc' && ( - <> - - - - - - - - - - - )} - - {network === 'xhttp' && ( - <> - - - - - - - - - - - )} - {xhttpMode === 'stream-up' && ( - - - - )} - - - - - - - - - - - - - - - - - - - - )} - - - - )} - - - - )} - {xhttpMode === 'packet-up' && ( - <> - - - - )} - - )} - - - - - )} - - {network === 'httpupgrade' && ( - <> - - - - - - - - - - - - - - )} - - {network === 'kcp' && ( - <> - - - - - - - - - - - - - - - - - - - - )} - - { - const a = (prev.streamSettings as { externalProxy?: unknown[] } | undefined)?.externalProxy; - const b = (curr.streamSettings as { externalProxy?: unknown[] } | undefined)?.externalProxy; - return (Array.isArray(a) ? a.length : 0) !== (Array.isArray(b) ? b.length : 0); - }} - > - {({ getFieldValue }) => { - const arr = getFieldValue(['streamSettings', 'externalProxy']); - const on = Array.isArray(arr) && arr.length > 0; - return ( - <> - - - - {on && ( - - {(fields, { add, remove }) => ( - <> - - - - - {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 ( - - - - - - ({ - value: a, - label: a, - }))} - /> - - - ); - }} - -
- ))} -
- - )} -
- )} - - ); - }} -
- - { - 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 && ( - <> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ({ value: c, label: c }))} - /> - - - - - - - - - ({ value: v, label: v }))} - /> - - - {({ getFieldValue, setFieldValue }) => { - const he = getFieldValue(['streamSettings', 'sockopt', 'happyEyeballs']); - const hasHe = he != null; - return ( - <> - - { - setFieldValue( - ['streamSettings', 'sockopt', 'happyEyeballs'], - v ? HappyEyeballsSchema.parse({}) : undefined, - ); - }} - /> - - {hasHe && ( - <> - - - - - - - - - - - - - - )} - - ); - }} - - - {(fields, { add, remove }) => ( - <> - - - - {fields.map((field) => ( - - - - - - - - - - - - - - - - ))} - - )} - - - )} - - ); - }} - - - - - ); - - const securityTab = ( - <> - - - - 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 } }); - const tlsOnly = proto === Protocols.HYSTERIA; - return ( - onSecurityChange(e.target.value)} - > - {!tlsOnly && {t('none')}} - TLS - {realityOk && Reality} - - ); - }} - - - - - prev.streamSettings?.security !== curr.streamSettings?.security - } - > - {({ getFieldValue }) => { - const sec = getFieldValue(['streamSettings', 'security']); - if (sec !== 'tls') return null; - return ( - <> - - - - - ({ value: v, label: v }))} - /> - - - ({ value: fp, label: fp })), - ]} - /> - - - - - - - - - - - - - - - ) : ( - <> - typeof v === 'string' - ? v.split('\n') - : v} - getValueProps={(v) => ({ - value: Array.isArray(v) ? v.join('\n') : v, - })} - > -