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:
MHSanaei
2026-07-15 05:51:13 +02:00
parent 982ab276e8
commit ebfaad1499
4 changed files with 101 additions and 21 deletions
@@ -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);
});
});