mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-29 22:47:14 +00:00
fix(frontend): map outbound row actions through the outbound's real index
The outbounds table hides balancer-loopback outbounds (`_bl_*`) but keeps each visible row's original index in `key`, then passed antd's positional row index to edit/delete/move and to the per-row probe (onTest) and its result lookup — all of which address the full, unfiltered outbounds array. Once a hidden loopback outbound precedes a visible one, the positional index diverges from the array index, so deleting or editing an outbound hit the wrong one (its deletion-impact plan and removal targeting the wrong entry), and the test button probed / showed results against the wrong outbound. Add originalOutboundIndex and route the mutating handlers through it; key the probe trigger and test-result columns by record.key. With no loopback rows hidden the mapping is the identity, so ordinary configs are unaffected.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -50,6 +50,7 @@ import type { XraySettingsValue, SetTemplate, OutboundTestMode, OutboundTestStat
|
|||||||
import './OutboundsTab.css';
|
import './OutboundsTab.css';
|
||||||
|
|
||||||
import type { OutboundRow } from './outbounds-tab-types';
|
import type { OutboundRow } from './outbounds-tab-types';
|
||||||
|
import { originalOutboundIndex } from './outbounds-tab-helpers';
|
||||||
import { useOutboundColumns } from './useOutboundColumns';
|
import { useOutboundColumns } from './useOutboundColumns';
|
||||||
import OutboundCardList from './OutboundCardList';
|
import OutboundCardList from './OutboundCardList';
|
||||||
import SubscriptionOutbounds from './SubscriptionOutbounds';
|
import SubscriptionOutbounds from './SubscriptionOutbounds';
|
||||||
@@ -150,6 +151,8 @@ export default function OutboundsTab({
|
|||||||
.filter((o) => !isBalancerLoopbackTag(o.tag || '')),
|
.filter((o) => !isBalancerLoopbackTag(o.tag || '')),
|
||||||
[outbounds],
|
[outbounds],
|
||||||
);
|
);
|
||||||
|
const rowsRef = useRef<OutboundRow[]>([]);
|
||||||
|
rowsRef.current = rows;
|
||||||
|
|
||||||
const dialerProxyTags = useMemo(() => {
|
const dialerProxyTags = useMemo(() => {
|
||||||
const tags = new Set<string>();
|
const tags = new Set<string>();
|
||||||
@@ -188,11 +191,12 @@ export default function OutboundsTab({
|
|||||||
loadSubs();
|
loadSubs();
|
||||||
}
|
}
|
||||||
function openEdit(idx: number) {
|
function openEdit(idx: number) {
|
||||||
setEditingOutbound((templateSettings?.outbounds || [])[idx] as Record<string, unknown>);
|
const target = originalOutboundIndex(rowsRef.current, idx);
|
||||||
setEditingIndex(idx);
|
setEditingOutbound((templateSettings?.outbounds || [])[target] as Record<string, unknown>);
|
||||||
|
setEditingIndex(target);
|
||||||
setExistingTags(
|
setExistingTags(
|
||||||
(templateSettings?.outbounds || [])
|
(templateSettings?.outbounds || [])
|
||||||
.filter((_, i) => i !== idx)
|
.filter((_, i) => i !== target)
|
||||||
.map((o) => o?.tag)
|
.map((o) => o?.tag)
|
||||||
.filter((tg): tg is string => !!tg),
|
.filter((tg): tg is string => !!tg),
|
||||||
);
|
);
|
||||||
@@ -217,8 +221,9 @@ export default function OutboundsTab({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function confirmDelete(idx: number) {
|
function confirmDelete(idx: number) {
|
||||||
|
const target = originalOutboundIndex(rowsRef.current, idx);
|
||||||
const impact = templateSettings
|
const impact = templateSettings
|
||||||
? planOutboundDeletion(templateSettings, idx)
|
? planOutboundDeletion(templateSettings, target)
|
||||||
: { rules: [], balancers: [], observatory: false, burst: false };
|
: { rules: [], balancers: [], observatory: false, burst: false };
|
||||||
modal.confirm({
|
modal.confirm({
|
||||||
title: `${t('delete')} ${t('pages.xray.Outbounds')} #${idx + 1}?`,
|
title: `${t('delete')} ${t('pages.xray.Outbounds')} #${idx + 1}?`,
|
||||||
@@ -226,27 +231,33 @@ export default function OutboundsTab({
|
|||||||
okText: t('delete'),
|
okText: t('delete'),
|
||||||
okType: 'danger',
|
okType: 'danger',
|
||||||
cancelText: t('cancel'),
|
cancelText: t('cancel'),
|
||||||
onOk: () => mutate((tt) => applyOutboundDeletion(tt, idx)),
|
onOk: () => mutate((tt) => applyOutboundDeletion(tt, target)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function setFirst(idx: number) {
|
function setFirst(idx: number) {
|
||||||
|
const target = originalOutboundIndex(rowsRef.current, idx);
|
||||||
mutate((tt) => {
|
mutate((tt) => {
|
||||||
if (!tt.outbounds) return;
|
if (!tt.outbounds) return;
|
||||||
const [moved] = tt.outbounds.splice(idx, 1);
|
const [moved] = tt.outbounds.splice(target, 1);
|
||||||
tt.outbounds.unshift(moved);
|
tt.outbounds.unshift(moved);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function moveUp(idx: number) {
|
function moveUp(idx: number) {
|
||||||
if (idx <= 0) return;
|
if (idx <= 0) return;
|
||||||
|
const target = originalOutboundIndex(rowsRef.current, idx);
|
||||||
|
const prev = originalOutboundIndex(rowsRef.current, idx - 1);
|
||||||
mutate((tt) => {
|
mutate((tt) => {
|
||||||
if (!tt.outbounds) return;
|
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) {
|
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) => {
|
mutate((tt) => {
|
||||||
if (!tt.outbounds || idx >= tt.outbounds.length - 1) return;
|
if (!tt.outbounds) return;
|
||||||
[tt.outbounds[idx + 1], tt.outbounds[idx]] = [tt.outbounds[idx], tt.outbounds[idx + 1]];
|
[tt.outbounds[next], tt.outbounds[target]] = [tt.outbounds[target], tt.outbounds[next]];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,19 @@ import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/
|
|||||||
|
|
||||||
import type { OutboundRow } from './outbounds-tab-types';
|
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[] {
|
export function outboundAddresses(o: OutboundRow): string[] {
|
||||||
const settings = o.settings as Record<string, unknown> | undefined;
|
const settings = o.settings as Record<string, unknown> | undefined;
|
||||||
switch (o.protocol) {
|
switch (o.protocol) {
|
||||||
|
|||||||
@@ -158,8 +158,8 @@ export function useOutboundColumns({
|
|||||||
key: 'egress',
|
key: 'egress',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
width: 210,
|
width: 210,
|
||||||
render: (_v, _record, index) => {
|
render: (_v, record) => {
|
||||||
const egress = testResult(outboundTestStates, index)?.egress;
|
const egress = testResult(outboundTestStates, record.key)?.egress;
|
||||||
const addresses = [
|
const addresses = [
|
||||||
egress?.ipv4 ? { label: 'v4', value: egress.ipv4 } : null,
|
egress?.ipv4 ? { label: 'v4', value: egress.ipv4 } : null,
|
||||||
egress?.ipv6 ? { label: 'v6', value: egress.ipv6 } : null,
|
egress?.ipv6 ? { label: 'v6', value: egress.ipv6 } : null,
|
||||||
@@ -190,8 +190,8 @@ export function useOutboundColumns({
|
|||||||
key: 'egressCountry',
|
key: 'egressCountry',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
width: 160,
|
width: 160,
|
||||||
render: (_v, _record, index) => {
|
render: (_v, record) => {
|
||||||
const egress = testResult(outboundTestStates, index)?.egress;
|
const egress = testResult(outboundTestStates, record.key)?.egress;
|
||||||
if (!egress?.country) {
|
if (!egress?.country) {
|
||||||
return (
|
return (
|
||||||
<Tooltip title={t('pages.xray.outbound.egressHint')}>
|
<Tooltip title={t('pages.xray.outbound.egressHint')}>
|
||||||
@@ -229,9 +229,9 @@ export function useOutboundColumns({
|
|||||||
key: 'testResult',
|
key: 'testResult',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
width: 140,
|
width: 140,
|
||||||
render: (_v, _record, index) => {
|
render: (_v, record) => {
|
||||||
const r = testResult(outboundTestStates, index);
|
const r = testResult(outboundTestStates, record.key);
|
||||||
if (!r) return isTesting(outboundTestStates, index) ? <LoadingOutlined /> : <span className="empty">—</span>;
|
if (!r) return isTesting(outboundTestStates, record.key) ? <LoadingOutlined /> : <span className="empty">—</span>;
|
||||||
return <TestResultPopover result={r} />;
|
return <TestResultPopover result={r} />;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -240,16 +240,16 @@ export function useOutboundColumns({
|
|||||||
key: 'test',
|
key: 'test',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 80,
|
width: 80,
|
||||||
render: (_v, record, index) => (
|
render: (_v, record) => (
|
||||||
<Tooltip title={`${t('check')} (${testModeLabel(effectiveTestMode(record, testMode), t)})`}>
|
<Tooltip title={`${t('check')} (${testModeLabel(effectiveTestMode(record, testMode), t)})`}>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
shape="circle"
|
shape="circle"
|
||||||
loading={isTesting(outboundTestStates, index)}
|
loading={isTesting(outboundTestStates, record.key)}
|
||||||
disabled={isUntestable(record) || isTesting(outboundTestStates, index)}
|
disabled={isUntestable(record) || isTesting(outboundTestStates, record.key)}
|
||||||
icon={<ThunderboltOutlined />}
|
icon={<ThunderboltOutlined />}
|
||||||
aria-label={t('check')}
|
aria-label={t('check')}
|
||||||
onClick={() => onTest(index, testMode)}
|
onClick={() => onTest(record.key, testMode)}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { fireEvent } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import OutboundsTab from '@/pages/xray/outbounds/OutboundsTab';
|
||||||
|
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||||
|
|
||||||
|
import { renderWithProviders } from './test-utils';
|
||||||
|
|
||||||
|
function settingsWithHiddenLoopback(): XraySettingsValue {
|
||||||
|
return {
|
||||||
|
outbounds: [
|
||||||
|
{ tag: 'proxy-a', protocol: 'vmess' },
|
||||||
|
{ tag: '_bl_bal1', protocol: 'loopback' },
|
||||||
|
{ tag: 'proxy-b', protocol: 'vmess' },
|
||||||
|
],
|
||||||
|
} as unknown as XraySettingsValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OutboundsTab hidden-loopback index mapping', () => {
|
||||||
|
it('probes the outbound at its real array index, not the positional row index', () => {
|
||||||
|
const onTest = vi.fn();
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<OutboundsTab
|
||||||
|
templateSettings={settingsWithHiddenLoopback()}
|
||||||
|
setTemplateSettings={vi.fn()}
|
||||||
|
outboundsTraffic={[]}
|
||||||
|
outboundTestStates={{}}
|
||||||
|
subscriptionTestStates={{}}
|
||||||
|
testingAll={false}
|
||||||
|
inboundTags={[]}
|
||||||
|
isMobile={false}
|
||||||
|
onResetTraffic={vi.fn()}
|
||||||
|
onTest={onTest}
|
||||||
|
onTestSubscription={vi.fn()}
|
||||||
|
onTestAll={vi.fn()}
|
||||||
|
onShowWarp={vi.fn()}
|
||||||
|
onShowNord={vi.fn()}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const tbody = document.querySelector('.ant-table-tbody');
|
||||||
|
const tableRows = tbody?.querySelectorAll('tr.ant-table-row') ?? [];
|
||||||
|
expect(tableRows.length).toBe(2);
|
||||||
|
|
||||||
|
const checkButton = tableRows[1].querySelector('button[aria-label="Check"]') as HTMLButtonElement;
|
||||||
|
fireEvent.click(checkButton);
|
||||||
|
|
||||||
|
expect(onTest).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onTest.mock.calls[0][0]).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user