Add Enable/Disable Toggle for Xray Routing Rules (#5296)

* feat: add enable/disable toggle for xray routing rules

* fix(routing): never let the internal api rule be disabled

The Enable/Disable toggle could strip the stats api rule: its table
switch was locked, but the rule-form modal's Enable dropdown was not,
and stripDisabledRules had no api-rule guard (EnsureStatsRouting's
delete only runs when the api rule isn't already first). A disabled
api rule then dropped out of the generated config and broke traffic
accounting.

- stripDisabledRules now always keeps the api rule, even if marked
  disabled, and strips the panel-only enabled key from every rule
- extract isApiRule helper (backend + frontend) and reuse it across
  the table switch, card switch, and form modal
- disable the form-modal Enable dropdown for the api rule
- add stripDisabledRules tests covering the api-rule survival path

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
Abdalrahman
2026-06-15 01:43:49 +03:00
committed by GitHub
parent 66a9a788fc
commit 53f6ed394f
12 changed files with 287 additions and 61 deletions
@@ -235,3 +235,7 @@
text-align: center;
}
.rule-disabled {
opacity: 0.5;
filter: grayscale(1);
}
@@ -58,6 +58,7 @@ export default function RoutingTab({
() =>
rules.map((rule, idx) => {
const r: RuleRow = { key: idx };
r.enabled = rule.enabled !== false;
r.domain = arrJoin(rule.domain);
r.ip = arrJoin(rule.ip);
r.port = rule.port;
@@ -185,6 +186,13 @@ export default function RoutingTab({
[list[idx + 1], list[idx]] = [list[idx], list[idx + 1]];
});
}
function toggleRule(idx: number, enabled: boolean) {
mutate((tt) => {
const list = tt.routing?.rules;
if (!list || !list[idx]) return;
list[idx].enabled = enabled;
});
}
function onHandlePointerDown(idx: number, ev: React.PointerEvent) {
if (ev.button != null && ev.button !== 0) return;
@@ -247,6 +255,7 @@ export default function RoutingTab({
moveUp,
moveDown,
confirmDelete,
toggleRule,
});
const tableScrollX = desktopColumns.reduce((sum, c) => {
@@ -289,6 +298,7 @@ export default function RoutingTab({
moveUp={moveUp}
moveDown={moveDown}
confirmDelete={confirmDelete}
toggleRule={toggleRule}
/>
) : (
<Table
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Tag, Tooltip } from 'antd';
import { Button, Dropdown, Tag, Tooltip, Switch } from 'antd';
import {
MoreOutlined,
EditOutlined,
@@ -13,7 +13,7 @@ import {
} from '@ant-design/icons';
import { useInboundOptions } from '@/api/queries/useInboundOptions';
import { buildRemarkByTag, chipPreview, inboundTagChipPreview, inboundTagsDisplayTitle, ruleCriteriaChips } from './helpers';
import { buildRemarkByTag, chipPreview, inboundTagChipPreview, inboundTagsDisplayTitle, isApiRule, ruleCriteriaChips } from './helpers';
import type { RuleRow } from './types';
interface RuleCardListProps {
@@ -25,6 +25,7 @@ interface RuleCardListProps {
moveUp: (idx: number) => void;
moveDown: (idx: number) => void;
confirmDelete: (idx: number) => void;
toggleRule: (idx: number, enabled: boolean) => void;
}
export default function RuleCardList({
@@ -36,6 +37,7 @@ export default function RuleCardList({
moveUp,
moveDown,
confirmDelete,
toggleRule,
}: RuleCardListProps) {
const { t } = useTranslation();
const { data: inboundOptions } = useInboundOptions();
@@ -50,7 +52,9 @@ export default function RuleCardList({
key={rule.key}
className={`rule-card ${draggedIndex === index ? 'row-dragging' : ''} ${
dropTargetIndex === index && draggedIndex != null && index < draggedIndex ? 'drop-before' : ''
} ${dropTargetIndex === index && draggedIndex != null && index > draggedIndex ? 'drop-after' : ''}`}
} ${dropTargetIndex === index && draggedIndex != null && index > draggedIndex ? 'drop-after' : ''} ${
rule.enabled === false ? 'rule-disabled' : ''
}`}
data-row-key={index}
>
<div className="rule-card-head">
@@ -72,6 +76,13 @@ export default function RuleCardList({
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
<Switch
size="small"
checked={rule.enabled !== false}
onChange={(checked) => toggleRule(index, checked)}
disabled={isApiRule(rule)}
style={{ marginLeft: 8 }}
/>
</div>
<div className="rule-flow">
@@ -5,9 +5,10 @@ import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design
import { InputAddon } from '@/components/ui';
import { useInboundOptions } from '@/api/queries/useInboundOptions';
import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
import { buildRemarkByTag, formatInboundTag } from './helpers';
import { buildRemarkByTag, formatInboundTag, isApiRule } from './helpers';
export interface RoutingRule {
enabled?: boolean;
type?: string;
domain?: string | string[];
ip?: string | string[];
@@ -38,6 +39,7 @@ interface RuleFormModalProps {
type FormState = RuleFormValues;
const initialForm = (): FormState => ({
enabled: true,
domain: '',
ip: '',
port: '',
@@ -81,6 +83,7 @@ export default function RuleFormModal({
if (!open) return;
if (rule) {
setForm({
enabled: rule.enabled !== false,
domain: Array.isArray(rule.domain) ? rule.domain.join(',') : rule.domain || '',
ip: Array.isArray(rule.ip) ? rule.ip.join(',') : rule.ip || '',
port: rule.port || '',
@@ -109,6 +112,7 @@ export default function RuleFormModal({
const v = validated.data;
const built: Record<string, unknown> = {
type: 'field',
enabled: v.enabled,
domain: csv(v.domain),
ip: csv(v.ip),
port: v.port,
@@ -151,6 +155,18 @@ export default function RuleFormModal({
onCancel={onClose}
>
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
<Form.Item label={t('enable')}>
<Select
value={form.enabled}
onChange={(v) => update('enabled', v)}
disabled={isApiRule(rule ?? {})}
options={[
{ value: true, label: t('enable') },
{ value: false, label: t('disable') },
]}
/>
</Form.Item>
<Form.Item
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
@@ -69,6 +69,13 @@ export function inboundTagChipPreview(
return chipPreviewParts(formatInboundTagList(tags, remarkByTag));
}
/** The internal api rule (stats traffic) — its enabled state must stay locked on. */
export function isApiRule(rule: { outboundTag?: string; inboundTag?: string | string[] }): boolean {
if (rule.outboundTag !== 'api') return false;
const tags = Array.isArray(rule.inboundTag) ? rule.inboundTag : csv(rule.inboundTag);
return tags.includes('api');
}
export function ruleCriteriaChips(rule: RuleRow) {
const chips: { label: string; value?: string }[] = [];
if (rule.domain) chips.push({ label: 'Domain', value: rule.domain });
+1
View File
@@ -1,5 +1,6 @@
export interface RuleRow {
key: number;
enabled?: boolean;
domain?: string;
ip?: string;
port?: string;
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Tag } from 'antd';
import { Button, Dropdown, Switch, Tag } from 'antd';
import {
MoreOutlined,
EditOutlined,
@@ -15,7 +15,7 @@ import type { ColumnsType } from 'antd/es/table';
import { useInboundOptions } from '@/api/queries/useInboundOptions';
import CriterionRow from './CriterionRow';
import { buildRemarkByTag, formatInboundTagList, inboundTagsDisplayTitle } from './helpers';
import { buildRemarkByTag, formatInboundTagList, inboundTagsDisplayTitle, isApiRule } from './helpers';
import type { RuleRow } from './types';
interface RoutingColumnsParams {
@@ -28,6 +28,7 @@ interface RoutingColumnsParams {
moveUp: (idx: number) => void;
moveDown: (idx: number) => void;
confirmDelete: (idx: number) => void;
toggleRule: (idx: number, enabled: boolean) => void;
}
export function useRoutingColumns({
@@ -40,6 +41,7 @@ export function useRoutingColumns({
moveUp,
moveDown,
confirmDelete,
toggleRule,
}: RoutingColumnsParams): ColumnsType<RuleRow> {
const { t } = useTranslation();
const { data: inboundOptions } = useInboundOptions();
@@ -49,44 +51,66 @@ export function useRoutingColumns({
{
title: '#',
align: 'center',
width: 100,
key: 'action',
width: 60,
key: 'index',
render: (_v, _r, index) => (
<div className="action-cell">
<div className="action-cell" style={{ justifyContent: 'center' }}>
<HolderOutlined
className="drag-handle"
title={t('pages.xray.routing.dragToReorder')}
onPointerDown={(ev: React.PointerEvent) => onHandlePointerDown(index, ev)}
/>
<span className="row-index">{index + 1}</span>
<div className={!isMobile ? 'action-buttons' : ''}>
{!isMobile && (
<Button shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
)}
<Dropdown
trigger={['click']}
menu={{
items: [
...(isMobile
? [{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) }]
: []),
{ key: 'up', label: <ArrowUpOutlined />, disabled: index === 0, onClick: () => moveUp(index) },
{
key: 'down',
label: <ArrowDownOutlined />,
disabled: index === rowsLength - 1,
onClick: () => moveDown(index),
},
{ key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</div>
</div>
),
},
{
title: t('pages.clients.actions'),
align: 'center',
width: 80,
key: 'action',
render: (_v, _r, index) => (
<div className={!isMobile ? 'action-buttons' : ''} style={{ justifyContent: 'center', margin: 0 }}>
{!isMobile && (
<Button shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
)}
<Dropdown
trigger={['click']}
menu={{
items: [
...(isMobile
? [{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) }]
: []),
{ key: 'up', label: <ArrowUpOutlined />, disabled: index === 0, onClick: () => moveUp(index) },
{
key: 'down',
label: <ArrowDownOutlined />,
disabled: index === rowsLength - 1,
onClick: () => moveDown(index),
},
{ key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</div>
),
},
{
title: t('enable'),
align: 'center',
width: 80,
key: 'enabled',
render: (_v, _r, index) => (
<Switch
size="small"
checked={_r.enabled !== false}
onChange={(checked) => toggleRule(index, checked)}
disabled={isApiRule(_r)}
/>
),
},
{
title: t('pages.xray.rules.source'),
align: 'left',
@@ -184,6 +208,6 @@ export function useRoutingColumns({
),
},
],
[t, isMobile, rowsLength, showSource, showBalancer, remarkByTag, onHandlePointerDown, openEdit, moveUp, moveDown, confirmDelete],
[t, isMobile, rowsLength, showSource, showBalancer, remarkByTag, onHandlePointerDown, openEdit, moveUp, moveDown, confirmDelete, toggleRule],
);
}
+1
View File
@@ -17,6 +17,7 @@ export type RuleWebhook = z.infer<typeof RuleWebhookSchema>;
export const RuleObjectSchema = z.object({
type: z.literal('field').default('field'),
enabled: z.boolean().optional(),
domain: z.array(z.string()).optional(),
ip: z.array(z.string()).optional(),
port: PortValueSchema.optional(),
+1
View File
@@ -84,6 +84,7 @@ export const OutboundTestResultSchema = z.object({
export const OutboundTestResultListSchema = z.array(OutboundTestResultSchema);
export const RuleFormSchema = z.object({
enabled: z.boolean(),
domain: z.string(),
ip: z.string(),
port: z.string(),