mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-12 22:31:00 +00:00
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.
This commit is contained in:
@@ -21,7 +21,7 @@ import RuleFormModal from './RuleFormModal';
|
|||||||
import type { RoutingRule } from './RuleFormModal';
|
import type { RoutingRule } from './RuleFormModal';
|
||||||
import RuleCardList from './RuleCardList';
|
import RuleCardList from './RuleCardList';
|
||||||
import { useRoutingColumns } from './useRoutingColumns';
|
import { useRoutingColumns } from './useRoutingColumns';
|
||||||
import { arrJoin } from './helpers';
|
import { arrJoin, originalRuleIndex } from './helpers';
|
||||||
import type { RuleRow } from './types';
|
import type { RuleRow } from './types';
|
||||||
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
|
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
|
||||||
import type { RuleObject } from '@/schemas/routing';
|
import type { RuleObject } from '@/schemas/routing';
|
||||||
@@ -64,6 +64,7 @@ export default function RoutingTab({
|
|||||||
);
|
);
|
||||||
const rulesRef = useRef(rules);
|
const rulesRef = useRef(rules);
|
||||||
rulesRef.current = rules;
|
rulesRef.current = rules;
|
||||||
|
const rowsRef = useRef<RuleRow[]>([]);
|
||||||
|
|
||||||
const rows: RuleRow[] = useMemo(
|
const rows: RuleRow[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -94,6 +95,7 @@ export default function RoutingTab({
|
|||||||
}),
|
}),
|
||||||
[rules],
|
[rules],
|
||||||
);
|
);
|
||||||
|
rowsRef.current = rows;
|
||||||
|
|
||||||
const mutate = useCallback(
|
const mutate = useCallback(
|
||||||
(mutator: (next: XraySettingsValue) => void) => {
|
(mutator: (next: XraySettingsValue) => void) => {
|
||||||
@@ -193,8 +195,9 @@ export default function RoutingTab({
|
|||||||
setRuleModalOpen(true);
|
setRuleModalOpen(true);
|
||||||
}
|
}
|
||||||
function openEdit(idx: number) {
|
function openEdit(idx: number) {
|
||||||
setEditingRule(rulesRef.current[idx]);
|
const target = originalRuleIndex(rowsRef.current, idx);
|
||||||
setEditingIndex(idx);
|
setEditingRule(rulesRef.current[target]);
|
||||||
|
setEditingIndex(target);
|
||||||
setRuleModalOpen(true);
|
setRuleModalOpen(true);
|
||||||
}
|
}
|
||||||
function onRuleConfirm(rule: Record<string, unknown>) {
|
function onRuleConfirm(rule: Record<string, unknown>) {
|
||||||
@@ -213,37 +216,44 @@ export default function RoutingTab({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function confirmDelete(idx: number) {
|
function confirmDelete(idx: number) {
|
||||||
|
const target = originalRuleIndex(rowsRef.current, idx);
|
||||||
modal.confirm({
|
modal.confirm({
|
||||||
title: `${t('delete')} ${t('pages.xray.Routings')} #${idx + 1}?`,
|
title: `${t('delete')} ${t('pages.xray.Routings')} #${idx + 1}?`,
|
||||||
okText: t('delete'),
|
okText: t('delete'),
|
||||||
okType: 'danger',
|
okType: 'danger',
|
||||||
cancelText: t('cancel'),
|
cancelText: t('cancel'),
|
||||||
onOk: () => mutate((tt) => {
|
onOk: () => mutate((tt) => {
|
||||||
tt.routing?.rules?.splice(idx, 1);
|
tt.routing?.rules?.splice(target, 1);
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveUp(idx: number) {
|
function moveUp(idx: number) {
|
||||||
if (idx <= 0) return;
|
if (idx <= 0) return;
|
||||||
|
const target = originalRuleIndex(rowsRef.current, idx);
|
||||||
|
const prev = originalRuleIndex(rowsRef.current, idx - 1);
|
||||||
mutate((tt) => {
|
mutate((tt) => {
|
||||||
const list = tt.routing?.rules;
|
const list = tt.routing?.rules;
|
||||||
if (!list) return;
|
if (!list || !list[target] || !list[prev]) return;
|
||||||
[list[idx - 1], list[idx]] = [list[idx], list[idx - 1]];
|
[list[prev], list[target]] = [list[target], list[prev]];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function moveDown(idx: number) {
|
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) => {
|
mutate((tt) => {
|
||||||
const list = tt.routing?.rules;
|
const list = tt.routing?.rules;
|
||||||
if (!list || idx >= list.length - 1) return;
|
if (!list || !list[target] || !list[next]) return;
|
||||||
[list[idx + 1], list[idx]] = [list[idx], list[idx + 1]];
|
[list[next], list[target]] = [list[target], list[next]];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function toggleRule(idx: number, enabled: boolean) {
|
function toggleRule(idx: number, enabled: boolean) {
|
||||||
|
const target = originalRuleIndex(rowsRef.current, idx);
|
||||||
mutate((tt) => {
|
mutate((tt) => {
|
||||||
const list = tt.routing?.rules;
|
const list = tt.routing?.rules;
|
||||||
if (!list || !list[idx]) return;
|
if (!list || !list[target]) return;
|
||||||
list[idx].enabled = enabled;
|
list[target].enabled = enabled;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,11 +292,13 @@ export default function RoutingTab({
|
|||||||
setDraggedIndex(null);
|
setDraggedIndex(null);
|
||||||
setDropTargetIndex(null);
|
setDropTargetIndex(null);
|
||||||
if (!moved || from == null || to == null || from === to) return;
|
if (!moved || from == null || to == null || from === to) return;
|
||||||
|
const fromOrig = originalRuleIndex(rowsRef.current, from);
|
||||||
|
const toOrig = originalRuleIndex(rowsRef.current, to);
|
||||||
mutate((tt) => {
|
mutate((tt) => {
|
||||||
const list = tt.routing?.rules;
|
const list = tt.routing?.rules;
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
const [movedItem] = list.splice(from, 1);
|
const [movedItem] = list.splice(fromOrig, 1);
|
||||||
list.splice(to, 0, movedItem);
|
list.splice(toOrig, 0, movedItem);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,18 @@ export function arrJoin(v: unknown): string | undefined {
|
|||||||
return String(v);
|
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[] {
|
export function csv(value?: string): string[] {
|
||||||
if (!value) return [];
|
if (!value) return [];
|
||||||
return String(value).split(',').map((s) => s.trim()).filter(Boolean);
|
return String(value).split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user