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 {
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<OutboundRow[]>([]);
rowsRef.current = rows;
const dialerProxyTags = useMemo(() => {
const tags = new Set<string>();
@@ -188,11 +191,12 @@ export default function OutboundsTab({
loadSubs();
}
function openEdit(idx: number) {
setEditingOutbound((templateSettings?.outbounds || [])[idx] as Record<string, unknown>);
setEditingIndex(idx);
const target = originalOutboundIndex(rowsRef.current, idx);
setEditingOutbound((templateSettings?.outbounds || [])[target] as Record<string, unknown>);
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]];
});
}
@@ -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<string, unknown> | undefined;
switch (o.protocol) {
@@ -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 (
<Tooltip title={t('pages.xray.outbound.egressHint')}>
@@ -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) ? <LoadingOutlined /> : <span className="empty"></span>;
render: (_v, record) => {
const r = testResult(outboundTestStates, record.key);
if (!r) return isTesting(outboundTestStates, record.key) ? <LoadingOutlined /> : <span className="empty"></span>;
return <TestResultPopover result={r} />;
},
},
@@ -240,16 +240,16 @@ export function useOutboundColumns({
key: 'test',
align: 'center',
width: 80,
render: (_v, record, index) => (
render: (_v, record) => (
<Tooltip title={`${t('check')} (${testModeLabel(effectiveTestMode(record, testMode), t)})`}>
<Button
type="primary"
shape="circle"
loading={isTesting(outboundTestStates, index)}
disabled={isUntestable(record) || isTesting(outboundTestStates, index)}
loading={isTesting(outboundTestStates, record.key)}
disabled={isUntestable(record) || isTesting(outboundTestStates, record.key)}
icon={<ThunderboltOutlined />}
aria-label={t('check')}
onClick={() => onTest(index, testMode)}
onClick={() => onTest(record.key, testMode)}
/>
</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);
});
});