Przeglądaj źródła

fix(frontend): map routing row actions through the rule's real index

The routing table hides balancer-loopback rules (`_bl_*`) but keeps each
visible row's original index in `key`, then handed antd's positional row
index straight to edit/delete/toggle/move/drag — all of which mutate the
full, unfiltered routing.rules array. Once a hidden loopback rule precedes a
visible one (e.g. a balancer whose fallback is another balancer, plus any
rule added afterwards), the positional index no longer matches the array
index, so deleting or editing a rule silently hit the wrong one — including
destroying the loopback rule that keeps the balancer alive.

Add originalRuleIndex to translate a positional row index back through the
row's `key`, and route every mutating handler (openEdit, confirmDelete,
toggleRule, moveUp/moveDown, drag) through it. When no loopback rows are
hidden the mapping is the identity, so ordinary configs are unaffected.
MHSanaei 1 dzień temu
rodzic
commit
982ab276e8

+ 24 - 12
frontend/src/pages/xray/routing/RoutingTab.tsx

@@ -21,7 +21,7 @@ import RuleFormModal from './RuleFormModal';
 import type { RoutingRule } from './RuleFormModal';
 import RuleCardList from './RuleCardList';
 import { useRoutingColumns } from './useRoutingColumns';
-import { arrJoin } from './helpers';
+import { arrJoin, originalRuleIndex } from './helpers';
 import type { RuleRow } from './types';
 import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
 import type { RuleObject } from '@/schemas/routing';
@@ -64,6 +64,7 @@ export default function RoutingTab({
   );
   const rulesRef = useRef(rules);
   rulesRef.current = rules;
+  const rowsRef = useRef<RuleRow[]>([]);
 
   const rows: RuleRow[] = useMemo(
     () =>
@@ -94,6 +95,7 @@ export default function RoutingTab({
         }),
     [rules],
   );
+  rowsRef.current = rows;
 
   const mutate = useCallback(
     (mutator: (next: XraySettingsValue) => void) => {
@@ -193,8 +195,9 @@ export default function RoutingTab({
     setRuleModalOpen(true);
   }
   function openEdit(idx: number) {
-    setEditingRule(rulesRef.current[idx]);
-    setEditingIndex(idx);
+    const target = originalRuleIndex(rowsRef.current, idx);
+    setEditingRule(rulesRef.current[target]);
+    setEditingIndex(target);
     setRuleModalOpen(true);
   }
   function onRuleConfirm(rule: Record<string, unknown>) {
@@ -213,37 +216,44 @@ export default function RoutingTab({
   }
 
   function confirmDelete(idx: number) {
+    const target = originalRuleIndex(rowsRef.current, idx);
     modal.confirm({
       title: `${t('delete')} ${t('pages.xray.Routings')} #${idx + 1}?`,
       okText: t('delete'),
       okType: 'danger',
       cancelText: t('cancel'),
       onOk: () => mutate((tt) => {
-        tt.routing?.rules?.splice(idx, 1);
+        tt.routing?.rules?.splice(target, 1);
       }),
     });
   }
 
   function moveUp(idx: number) {
     if (idx <= 0) return;
+    const target = originalRuleIndex(rowsRef.current, idx);
+    const prev = originalRuleIndex(rowsRef.current, idx - 1);
     mutate((tt) => {
       const list = tt.routing?.rules;
-      if (!list) return;
-      [list[idx - 1], list[idx]] = [list[idx], list[idx - 1]];
+      if (!list || !list[target] || !list[prev]) return;
+      [list[prev], list[target]] = [list[target], list[prev]];
     });
   }
   function moveDown(idx: number) {
+    if (idx >= rowsRef.current.length - 1) return;
+    const target = originalRuleIndex(rowsRef.current, idx);
+    const next = originalRuleIndex(rowsRef.current, idx + 1);
     mutate((tt) => {
       const list = tt.routing?.rules;
-      if (!list || idx >= list.length - 1) return;
-      [list[idx + 1], list[idx]] = [list[idx], list[idx + 1]];
+      if (!list || !list[target] || !list[next]) return;
+      [list[next], list[target]] = [list[target], list[next]];
     });
   }
   function toggleRule(idx: number, enabled: boolean) {
+    const target = originalRuleIndex(rowsRef.current, idx);
     mutate((tt) => {
       const list = tt.routing?.rules;
-      if (!list || !list[idx]) return;
-      list[idx].enabled = enabled;
+      if (!list || !list[target]) return;
+      list[target].enabled = enabled;
     });
   }
 
@@ -282,11 +292,13 @@ export default function RoutingTab({
       setDraggedIndex(null);
       setDropTargetIndex(null);
       if (!moved || from == null || to == null || from === to) return;
+      const fromOrig = originalRuleIndex(rowsRef.current, from);
+      const toOrig = originalRuleIndex(rowsRef.current, to);
       mutate((tt) => {
         const list = tt.routing?.rules;
         if (!list) return;
-        const [movedItem] = list.splice(from, 1);
-        list.splice(to, 0, movedItem);
+        const [movedItem] = list.splice(fromOrig, 1);
+        list.splice(toOrig, 0, movedItem);
       });
     };
 

+ 12 - 0
frontend/src/pages/xray/routing/helpers.ts

@@ -6,6 +6,18 @@ export function arrJoin(v: unknown): string | undefined {
   return String(v);
 }
 
+/**
+ * Translate a table row's positional index into that rule's index in the full,
+ * unfiltered routing.rules array. The table hides balancer-loopback rules but
+ * keeps each visible row's original index in `key`, so any handler that mutates
+ * routing.rules must map the positional index back through `key` or it operates
+ * on the wrong rule once a hidden loopback precedes it.
+ */
+export function originalRuleIndex(rows: RuleRow[], positionalIndex: number): number {
+  const row = rows[positionalIndex];
+  return row ? row.key : positionalIndex;
+}
+
 export function csv(value?: string): string[] {
   if (!value) return [];
   return String(value).split(',').map((s) => s.trim()).filter(Boolean);

+ 55 - 0
frontend/src/test/routing-loopback-index.test.tsx

@@ -0,0 +1,55 @@
+import { describe, it, expect, vi } from 'vitest';
+import { fireEvent, screen } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+
+import RoutingTab from '@/pages/xray/routing/RoutingTab';
+import type { XraySettingsValue } from '@/hooks/useXraySetting';
+
+import { renderWithProviders } from './test-utils';
+
+function settingsWithHiddenLoopback(): XraySettingsValue {
+  return {
+    routing: {
+      rules: [
+        { type: 'field', outboundTag: 'a', enabled: true },
+        { type: 'field', inboundTag: ['_bl_bal1'], outboundTag: 'b', enabled: true },
+        { type: 'field', outboundTag: 'c', enabled: true },
+      ],
+    },
+  } as unknown as XraySettingsValue;
+}
+
+describe('RoutingTab hidden-loopback index mapping', () => {
+  it('toggles the visible rule, not the hidden loopback rule that precedes it', () => {
+    const setTemplateSettings = vi.fn();
+    const initial = settingsWithHiddenLoopback();
+
+    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+    renderWithProviders(
+      <QueryClientProvider client={queryClient}>
+        <RoutingTab
+          templateSettings={initial}
+          setTemplateSettings={setTemplateSettings}
+          inboundTags={[]}
+          clientReverseTags={[]}
+          isMobile={false}
+        />
+      </QueryClientProvider>,
+    );
+
+    fireEvent.click(screen.getByRole('tab', { name: /Routing Rules/ }));
+
+    const switches = document.querySelectorAll('.routing-table .ant-switch');
+    expect(switches.length).toBe(2);
+
+    fireEvent.click(switches[1]);
+
+    expect(setTemplateSettings).toHaveBeenCalledTimes(1);
+    const updater = setTemplateSettings.mock.calls[0][0] as (prev: XraySettingsValue) => XraySettingsValue;
+    const next = updater(initial);
+    const rules = (next.routing as { rules: Array<{ enabled?: boolean }> }).rules;
+
+    expect(rules[2].enabled).toBe(false);
+    expect(rules[1].enabled).toBe(true);
+  });
+});