diff --git a/frontend/src/api/http-init.ts b/frontend/src/api/http-init.ts index 2ead50f9f..2261d0d90 100644 --- a/frontend/src/api/http-init.ts +++ b/frontend/src/api/http-init.ts @@ -152,8 +152,11 @@ export async function httpRequest( if (res.status === 403 && !SAFE_METHODS.has(method.toUpperCase())) { csrfToken = null; - const fresh = await ensureCsrfToken(); - if (fresh) res = await performFetch(method, url, data, options, fresh); + const fresh = await fetchCsrfToken(); + if (fresh) { + csrfToken = fresh; + res = await performFetch(method, url, data, options, fresh); + } } if (res.status === 401) { diff --git a/frontend/src/api/websocket.ts b/frontend/src/api/websocket.ts index 3db7cdfa6..a8459b50a 100644 --- a/frontend/src/api/websocket.ts +++ b/frontend/src/api/websocket.ts @@ -190,3 +190,12 @@ export class WebSocketClient { } } } + +let sharedClient: WebSocketClient | null = null; + +export function getSharedWebSocketClient(): WebSocketClient { + if (sharedClient) return sharedClient; + const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || ''; + sharedClient = new WebSocketClient(basePath); + return sharedClient; +} diff --git a/frontend/src/api/websocketBridge.ts b/frontend/src/api/websocketBridge.ts index b6d942249..9394398c8 100644 --- a/frontend/src/api/websocketBridge.ts +++ b/frontend/src/api/websocketBridge.ts @@ -1,34 +1,19 @@ import { useEffect } from 'react'; import { useQueryClient } from '@tanstack/react-query'; -import { WebSocketClient } from '@/api/websocket'; +import { getSharedWebSocketClient } from '@/api/websocket'; import { keys } from '@/api/queryKeys'; import { isRecentLocalInvalidate } from '@/api/invalidationTracker'; type Handler = (payload: unknown) => void; -interface SharedClient { - connect(): void; - on(event: string, fn: Handler): void; - off(event: string, fn: Handler): void; -} - -let sharedClient: SharedClient | null = null; - -function getSharedClient(): SharedClient { - if (sharedClient) return sharedClient; - const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || ''; - sharedClient = new WebSocketClient(basePath) as SharedClient; - return sharedClient; -} - let invalidateTimer: number | null = null; export function useWebSocketBridge() { const queryClient = useQueryClient(); useEffect(() => { - const client = getSharedClient(); + const client = getSharedWebSocketClient(); const onInvalidate: Handler = (payload) => { const p = payload as { type?: string } | undefined; @@ -46,6 +31,7 @@ export function useWebSocketBridge() { }; const onOutbounds: Handler = (payload) => { + if (!Array.isArray(payload)) return; queryClient.setQueryData(keys.xray.outboundsTraffic(), payload); }; diff --git a/frontend/src/components/clients/ConfigBlock.css b/frontend/src/components/clients/ConfigBlock.css index 5a7567786..474158099 100644 --- a/frontend/src/components/clients/ConfigBlock.css +++ b/frontend/src/components/clients/ConfigBlock.css @@ -38,6 +38,7 @@ body.light .config-block .ant-tag.ant-tag-filled.ant-tag-gold { word-break: break-all; white-space: pre-wrap; padding: 6px 8px; + color: var(--ant-color-text); background: var(--ant-color-fill-tertiary); border-radius: 4px; user-select: all; diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index 4a5e036e9..1eccb41d4 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -1,26 +1,11 @@ import { useEffect } from 'react'; -import { WebSocketClient } from '@/api/websocket'; +import { getSharedWebSocketClient } from '@/api/websocket'; type Handler = (payload: unknown) => void; -interface SharedClient { - connect(): void; - on(event: string, fn: Handler): void; - off(event: string, fn: Handler): void; -} - -let sharedClient: SharedClient | null = null; - -function getSharedClient(): SharedClient { - if (sharedClient) return sharedClient; - const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || ''; - sharedClient = new WebSocketClient(basePath) as SharedClient; - return sharedClient; -} - export function useWebSocket(handlers: Record) { useEffect(() => { - const client = getSharedClient(); + const client = getSharedWebSocketClient(); const entries = Object.entries(handlers); for (const [event, fn] of entries) client.on(event, fn); client.connect(); diff --git a/frontend/src/lib/xray/forms/fields/SniffingField.tsx b/frontend/src/lib/xray/forms/fields/SniffingField.tsx index aee7308a0..5369f8d99 100644 --- a/frontend/src/lib/xray/forms/fields/SniffingField.tsx +++ b/frontend/src/lib/xray/forms/fields/SniffingField.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { Form } from 'antd'; import SniffingFields from '@/lib/xray/forms/SniffingFields'; -import type { Sniffing } from '@/schemas/primitives/sniffing'; +import { SniffingSchema, type Sniffing } from '@/schemas/primitives/sniffing'; interface SniffingFieldProps { value?: Sniffing; @@ -12,12 +12,12 @@ interface SniffingFieldProps { export default function SniffingField({ value, onChange, enableLabel }: SniffingFieldProps) { const [form] = Form.useForm(); - const [initial] = useState(() => value ?? ({} as Sniffing)); + const [initial] = useState(() => value ?? SniffingSchema.parse({})); const onChangeRef = useRef(onChange); onChangeRef.current = onChange; const lastEmitted = useRef(JSON.stringify(initial)); - const sniffing = Form.useWatch('sniffing', form) as Sniffing | undefined; + const sniffing = Form.useWatch('sniffing', { form, preserve: true }) as Sniffing | undefined; useEffect(() => { if (sniffing === undefined) return; @@ -27,6 +27,14 @@ export default function SniffingField({ value, onChange, enableLabel }: Sniffing onChangeRef.current?.(sniffing); }, [sniffing]); + useEffect(() => { + if (value === undefined) return; + const serialized = JSON.stringify(value); + if (serialized === lastEmitted.current) return; + lastEmitted.current = serialized; + form.setFieldsValue({ sniffing: value }); + }, [value, form]); + return (
= { email: values.email.trim(), subId: values.subId, @@ -499,7 +507,7 @@ export default function ClientFormModal({ auth: values.auth, flow: showFlow ? (values.flow || '') : '', security: showSecurity ? (values.security || 'auto') : 'auto', - totalGB: gbToBytes(values.totalGB), + totalGB: totalBytes, expiryTime, reset: Number(values.reset) || 0, limitIp: Number(values.limitIp) || 0, diff --git a/frontend/src/pages/nodes/NodeList.tsx b/frontend/src/pages/nodes/NodeList.tsx index 0b850af24..aec149054 100644 --- a/frontend/src/pages/nodes/NodeList.tsx +++ b/frontend/src/pages/nodes/NodeList.tsx @@ -650,7 +650,7 @@ export default function NodeList({ loading={loading} scroll={{ x: 'max-content' }} size="middle" - rowKey="id" + rowKey="key" rowSelection={dataSource.length > 1 ? { selectedRowKeys: selectedIds, onChange: (keys) => onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]), diff --git a/frontend/src/pages/xray/basics/BasicsTab.tsx b/frontend/src/pages/xray/basics/BasicsTab.tsx index 115f031ab..d29a67f79 100644 --- a/frontend/src/pages/xray/basics/BasicsTab.tsx +++ b/frontend/src/pages/xray/basics/BasicsTab.tsx @@ -115,7 +115,8 @@ export default function BasicsTab({ ?.sockopt; const raw = sockopt?.happyEyeballs; if (raw == null || typeof raw !== 'object') return null; - return HappyEyeballsSchema.parse(raw); + const parsed = HappyEyeballsSchema.safeParse(raw); + return parsed.success ? parsed.data : null; })(); const setDirectHappyEyeballs = useCallback( diff --git a/frontend/src/pages/xray/outbounds/OutboundCardList.tsx b/frontend/src/pages/xray/outbounds/OutboundCardList.tsx index 967bfae75..706dad1d3 100644 --- a/frontend/src/pages/xray/outbounds/OutboundCardList.tsx +++ b/frontend/src/pages/xray/outbounds/OutboundCardList.tsx @@ -63,8 +63,8 @@ export default function OutboundCardList({ setShowEgressIp((prev) => ({ ...prev, [key]: visible })); }; - const renderEgress = (index: number, rowKey: string) => { - const result = testResult(outboundTestStates, index); + const renderEgress = (testKey: number, rowKey: string) => { + const result = testResult(outboundTestStates, testKey); const egress = result?.egress; const isEgressVisible = !!showEgressIp[rowKey]; const flag = countryFlag(egress?.country); @@ -156,26 +156,26 @@ export default function OutboundCardList({ ))} )} - {renderEgress(index, String(record.key))} + {renderEgress(record.key, String(record.key))}
↑ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).up)} ↓ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).down)} - {testResult(outboundTestStates, index) ? ( - - ) : isTesting(outboundTestStates, index) ? ( + {testResult(outboundTestStates, record.key) ? ( + + ) : isTesting(outboundTestStates, record.key) ? ( ) : null}
diff --git a/frontend/src/pages/xray/outbounds/OutboundsTab.tsx b/frontend/src/pages/xray/outbounds/OutboundsTab.tsx index 03adb5173..662b9187b 100644 --- a/frontend/src/pages/xray/outbounds/OutboundsTab.tsx +++ b/frontend/src/pages/xray/outbounds/OutboundsTab.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, @@ -50,6 +50,7 @@ import type { XraySettingsValue, SetTemplate, OutboundTestMode, OutboundTestStat import './OutboundsTab.css'; import type { OutboundRow } from './outbounds-tab-types'; +import { originalOutboundIndex } from './outbounds-tab-helpers'; import { useOutboundColumns } from './useOutboundColumns'; import OutboundCardList from './OutboundCardList'; import SubscriptionOutbounds from './SubscriptionOutbounds'; @@ -150,6 +151,8 @@ export default function OutboundsTab({ .filter((o) => !isBalancerLoopbackTag(o.tag || '')), [outbounds], ); + const rowsRef = useRef([]); + rowsRef.current = rows; const dialerProxyTags = useMemo(() => { const tags = new Set(); @@ -188,11 +191,12 @@ export default function OutboundsTab({ loadSubs(); } function openEdit(idx: number) { - setEditingOutbound((templateSettings?.outbounds || [])[idx] as Record); - setEditingIndex(idx); + const target = originalOutboundIndex(rowsRef.current, idx); + setEditingOutbound((templateSettings?.outbounds || [])[target] as Record); + setEditingIndex(target); setExistingTags( (templateSettings?.outbounds || []) - .filter((_, i) => i !== idx) + .filter((_, i) => i !== target) .map((o) => o?.tag) .filter((tg): tg is string => !!tg), ); @@ -217,8 +221,9 @@ export default function OutboundsTab({ } function confirmDelete(idx: number) { + const target = originalOutboundIndex(rowsRef.current, idx); const impact = templateSettings - ? planOutboundDeletion(templateSettings, idx) + ? planOutboundDeletion(templateSettings, target) : { rules: [], balancers: [], observatory: false, burst: false }; modal.confirm({ title: `${t('delete')} ${t('pages.xray.Outbounds')} #${idx + 1}?`, @@ -226,27 +231,33 @@ export default function OutboundsTab({ okText: t('delete'), okType: 'danger', cancelText: t('cancel'), - onOk: () => mutate((tt) => applyOutboundDeletion(tt, idx)), + onOk: () => mutate((tt) => applyOutboundDeletion(tt, target)), }); } function setFirst(idx: number) { + const target = originalOutboundIndex(rowsRef.current, idx); mutate((tt) => { if (!tt.outbounds) return; - const [moved] = tt.outbounds.splice(idx, 1); + const [moved] = tt.outbounds.splice(target, 1); tt.outbounds.unshift(moved); }); } function moveUp(idx: number) { if (idx <= 0) return; + const target = originalOutboundIndex(rowsRef.current, idx); + const prev = originalOutboundIndex(rowsRef.current, idx - 1); mutate((tt) => { if (!tt.outbounds) return; - [tt.outbounds[idx - 1], tt.outbounds[idx]] = [tt.outbounds[idx], tt.outbounds[idx - 1]]; + [tt.outbounds[prev], tt.outbounds[target]] = [tt.outbounds[target], tt.outbounds[prev]]; }); } function moveDown(idx: number) { + if (idx >= rowsRef.current.length - 1) return; + const target = originalOutboundIndex(rowsRef.current, idx); + const next = originalOutboundIndex(rowsRef.current, idx + 1); mutate((tt) => { - if (!tt.outbounds || idx >= tt.outbounds.length - 1) return; - [tt.outbounds[idx + 1], tt.outbounds[idx]] = [tt.outbounds[idx], tt.outbounds[idx + 1]]; + if (!tt.outbounds) return; + [tt.outbounds[next], tt.outbounds[target]] = [tt.outbounds[target], tt.outbounds[next]]; }); } diff --git a/frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts b/frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts index f915e464d..254aa2479 100644 --- a/frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts +++ b/frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts @@ -6,6 +6,19 @@ import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/ import type { OutboundRow } from './outbounds-tab-types'; +/** + * Translate a table row's positional index into that outbound's index in the + * full, unfiltered outbounds array. The table hides balancer-loopback outbounds + * but keeps each visible row's original index in `key`, so any handler that + * mutates the outbounds array (or probes an outbound by index) must map the + * positional index back through `key` or it operates on the wrong outbound once + * a hidden loopback precedes it. + */ +export function originalOutboundIndex(rows: OutboundRow[], positionalIndex: number): number { + const row = rows[positionalIndex]; + return row ? row.key : positionalIndex; +} + export function outboundAddresses(o: OutboundRow): string[] { const settings = o.settings as Record | undefined; switch (o.protocol) { diff --git a/frontend/src/pages/xray/outbounds/useOutboundColumns.tsx b/frontend/src/pages/xray/outbounds/useOutboundColumns.tsx index ab6f4e071..a4d874584 100644 --- a/frontend/src/pages/xray/outbounds/useOutboundColumns.tsx +++ b/frontend/src/pages/xray/outbounds/useOutboundColumns.tsx @@ -158,8 +158,8 @@ export function useOutboundColumns({ key: 'egress', align: 'left', width: 210, - render: (_v, _record, index) => { - const egress = testResult(outboundTestStates, index)?.egress; + render: (_v, record) => { + const egress = testResult(outboundTestStates, record.key)?.egress; const addresses = [ egress?.ipv4 ? { label: 'v4', value: egress.ipv4 } : null, egress?.ipv6 ? { label: 'v6', value: egress.ipv6 } : null, @@ -190,8 +190,8 @@ export function useOutboundColumns({ key: 'egressCountry', align: 'left', width: 160, - render: (_v, _record, index) => { - const egress = testResult(outboundTestStates, index)?.egress; + render: (_v, record) => { + const egress = testResult(outboundTestStates, record.key)?.egress; if (!egress?.country) { return ( @@ -229,9 +229,9 @@ export function useOutboundColumns({ key: 'testResult', align: 'left', width: 140, - render: (_v, _record, index) => { - const r = testResult(outboundTestStates, index); - if (!r) return isTesting(outboundTestStates, index) ? : ; + render: (_v, record) => { + const r = testResult(outboundTestStates, record.key); + if (!r) return isTesting(outboundTestStates, record.key) ? : ; return ; }, }, @@ -240,16 +240,16 @@ export function useOutboundColumns({ key: 'test', align: 'center', width: 80, - render: (_v, record, index) => ( + render: (_v, record) => (