Преглед на файлове

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.
MHSanaei преди 1 ден
родител
ревизия
ebfaad1499

+ 21 - 10
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<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]];
     });
   }
 

+ 13 - 0
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<string, unknown> | undefined;
   switch (o.protocol) {

+ 11 - 11
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 (
               <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>
         ),

+ 56 - 0
frontend/src/test/outbounds-loopback-index.test.tsx

@@ -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);
+  });
+});