mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-15 17:16:07 +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 {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user