From 982ab276e82d8d84cc7c8e46f822495f40538ef2 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 15 Jul 2026 05:42:16 +0200 Subject: [PATCH] fix(frontend): map routing row actions through the rule's real index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/pages/xray/routing/RoutingTab.tsx | 36 ++++++++---- frontend/src/pages/xray/routing/helpers.ts | 12 ++++ .../src/test/routing-loopback-index.test.tsx | 55 +++++++++++++++++++ 3 files changed, 91 insertions(+), 12 deletions(-) create mode 100644 frontend/src/test/routing-loopback-index.test.tsx diff --git a/frontend/src/pages/xray/routing/RoutingTab.tsx b/frontend/src/pages/xray/routing/RoutingTab.tsx index 55b7135d1..1b54a6213 100644 --- a/frontend/src/pages/xray/routing/RoutingTab.tsx +++ b/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([]); 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) { @@ -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); }); }; diff --git a/frontend/src/pages/xray/routing/helpers.ts b/frontend/src/pages/xray/routing/helpers.ts index 0f3f45172..9c19e68c9 100644 --- a/frontend/src/pages/xray/routing/helpers.ts +++ b/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); diff --git a/frontend/src/test/routing-loopback-index.test.tsx b/frontend/src/test/routing-loopback-index.test.tsx new file mode 100644 index 000000000..e108da849 --- /dev/null +++ b/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( + + + , + ); + + 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); + }); +});