diff --git a/frontend/src/pages/xray/DeletionImpactList.tsx b/frontend/src/pages/xray/DeletionImpactList.tsx
new file mode 100644
index 000000000..fbb546ff3
--- /dev/null
+++ b/frontend/src/pages/xray/DeletionImpactList.tsx
@@ -0,0 +1,40 @@
+import { useTranslation } from 'react-i18next';
+
+import type { DeletionImpact } from './reference-cleanup';
+
+interface DeletionImpactListProps {
+ impact: DeletionImpact;
+}
+
+export default function DeletionImpactList({ impact }: DeletionImpactListProps) {
+ const { t } = useTranslation();
+
+ const lines: string[] = [];
+ for (const rule of impact.rules) {
+ lines.push(
+ rule.fate === 'removed'
+ ? t('pages.xray.refCleanup.ruleRemoved', { label: rule.label })
+ : t('pages.xray.refCleanup.ruleModified', { label: rule.label, keeps: rule.keeps ?? '' }),
+ );
+ }
+ for (const balancer of impact.balancers) {
+ lines.push(t('pages.xray.refCleanup.balancerRemoved', { tag: balancer.tag }));
+ }
+ if (impact.observatory) lines.push(t('pages.xray.observatory.deleteAlsoObservatory'));
+ if (impact.burst) lines.push(t('pages.xray.observatory.deleteAlsoBurst'));
+
+ if (lines.length === 0) return null;
+
+ return (
+
+
{t('pages.xray.refCleanup.header')}
+
+ {lines.map((line, i) => (
+ -
+ {line}
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/pages/xray/balancers/BalancersTab.tsx b/frontend/src/pages/xray/balancers/BalancersTab.tsx
index 269c50abf..338b12429 100644
--- a/frontend/src/pages/xray/balancers/BalancersTab.tsx
+++ b/frontend/src/pages/xray/balancers/BalancersTab.tsx
@@ -6,7 +6,9 @@ import type { ColumnsType } from 'antd/es/table';
import BalancerFormModal from './BalancerFormModal';
import type { BalancerFormValue } from './BalancerFormModal';
-import { syncObservatories, observersRemovedByDeletingBalancer } from './balancer-helpers';
+import { syncObservatories } from './balancer-helpers';
+import { planBalancerDeletion, applyBalancerDeletion } from '../reference-cleanup';
+import DeletionImpactList from '../DeletionImpactList';
import ObservatorySettingsTab from './ObservatorySettingsTab';
import { catTabLabel } from '@/pages/settings/catTabLabel';
import { HttpUtil } from '@/utils';
@@ -185,24 +187,16 @@ export default function BalancersTab({
}
function confirmDelete(idx: number) {
- const removed = templateSettings
- ? observersRemovedByDeletingBalancer(templateSettings, idx)
- : { observatory: false, burst: false };
- const warnings: string[] = [];
- if (removed.observatory) warnings.push(t('pages.xray.observatory.deleteAlsoObservatory'));
- if (removed.burst) warnings.push(t('pages.xray.observatory.deleteAlsoBurst'));
+ const impact = templateSettings
+ ? planBalancerDeletion(templateSettings, idx)
+ : { rules: [], balancers: [], observatory: false, burst: false };
modal.confirm({
title: `${t('delete')} ${t('pages.xray.Balancers')} #${idx + 1}?`,
- content: warnings.length ? warnings.join(' ') : undefined,
+ content: ,
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
- onOk: () => mutate((tt) => {
- if (tt.routing?.balancers) {
- tt.routing.balancers.splice(idx, 1);
- syncObservatories(tt);
- }
- }),
+ onOk: () => mutate((tt) => applyBalancerDeletion(tt, idx)),
});
}
diff --git a/frontend/src/pages/xray/balancers/balancer-helpers.ts b/frontend/src/pages/xray/balancers/balancer-helpers.ts
index eb53d1a2c..a692f70d2 100644
--- a/frontend/src/pages/xray/balancers/balancer-helpers.ts
+++ b/frontend/src/pages/xray/balancers/balancer-helpers.ts
@@ -72,19 +72,3 @@ export function syncObservatories(t: XraySettingsValue) {
delete t.burstObservatory;
}
}
-
-export function observersRemovedByDeletingBalancer(
- t: XraySettingsValue,
- idx: number,
-): { observatory: boolean; burst: boolean } {
- const hadObservatory = !!t.observatory;
- const hadBurst = !!t.burstObservatory;
- if (!hadObservatory && !hadBurst) return { observatory: false, burst: false };
- const clone = JSON.parse(JSON.stringify(t)) as XraySettingsValue;
- if (clone.routing?.balancers) clone.routing.balancers.splice(idx, 1);
- syncObservatories(clone);
- return {
- observatory: hadObservatory && !clone.observatory,
- burst: hadBurst && !clone.burstObservatory,
- };
-}
diff --git a/frontend/src/pages/xray/outbounds/OutboundsTab.tsx b/frontend/src/pages/xray/outbounds/OutboundsTab.tsx
index 976282add..890df06cc 100644
--- a/frontend/src/pages/xray/outbounds/OutboundsTab.tsx
+++ b/frontend/src/pages/xray/outbounds/OutboundsTab.tsx
@@ -43,6 +43,8 @@ import TextModal from '@/components/feedback/TextModal';
import OutboundFormModal from './OutboundFormModal';
import { propagateOutboundTagRename } from '../basics/helpers';
+import { planOutboundDeletion, applyOutboundDeletion } from '../reference-cleanup';
+import DeletionImpactList from '../DeletionImpactList';
import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
import './OutboundsTab.css';
@@ -208,16 +210,16 @@ export default function OutboundsTab({
}
function confirmDelete(idx: number) {
+ const impact = templateSettings
+ ? planOutboundDeletion(templateSettings, idx)
+ : { rules: [], balancers: [], observatory: false, burst: false };
modal.confirm({
title: `${t('delete')} ${t('pages.xray.Outbounds')} #${idx + 1}?`,
+ content: ,
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
- onOk: () => {
- mutate((tt) => {
- tt.outbounds?.splice(idx, 1);
- });
- },
+ onOk: () => mutate((tt) => applyOutboundDeletion(tt, idx)),
});
}
function setFirst(idx: number) {
diff --git a/frontend/src/pages/xray/reference-cleanup.ts b/frontend/src/pages/xray/reference-cleanup.ts
new file mode 100644
index 000000000..25991ae32
--- /dev/null
+++ b/frontend/src/pages/xray/reference-cleanup.ts
@@ -0,0 +1,236 @@
+import type { XraySettingsValue } from '@/hooks/useXraySetting';
+import type { BalancerObject, RuleObject } from '@/schemas/routing';
+import { syncObservatories } from './balancers/balancer-helpers';
+
+/**
+ * Reference cleanup for the Xray-config blob: when an outbound or balancer is
+ * deleted, routing rules and balancers that point at it must be repaired in the
+ * same edit, or the saved config breaks the core (a dangling balancerTag stops
+ * Router.Init; a dangling outboundTag black-holes matched traffic).
+ *
+ * Keep/drop a rule by its destination: after the deletion, a rule that still has
+ * an outboundTag or balancerTag is kept (the dead reference is dropped); a rule
+ * left with neither is removed, since a destination-less rule black-holes the
+ * traffic it matches. Deleting an outbound cascades: if it empties a balancer's
+ * selector, that balancer is removed too, and its rules are repaired the same way.
+ */
+
+export type RuleFate = 'removed' | 'modified';
+
+export interface RuleImpact {
+ index: number;
+ label: string;
+ fate: RuleFate;
+ keeps?: string;
+}
+
+export interface BalancerImpact {
+ tag: string;
+ reason: 'selectorEmptied';
+}
+
+export interface DeletionImpact {
+ rules: RuleImpact[];
+ balancers: BalancerImpact[];
+ observatory: boolean;
+ burst: boolean;
+}
+
+const emptyImpact = (): DeletionImpact => ({ rules: [], balancers: [], observatory: false, burst: false });
+
+function ruleList(tt: XraySettingsValue): RuleObject[] {
+ const r = tt.routing?.rules;
+ return Array.isArray(r) ? r : [];
+}
+
+function balancerList(tt: XraySettingsValue): BalancerObject[] {
+ const b = tt.routing?.balancers;
+ return Array.isArray(b) ? b : [];
+}
+
+function outboundTagAt(tt: XraySettingsValue, index: number): string {
+ const o = tt.outbounds?.[index];
+ return typeof o?.tag === 'string' ? o.tag : '';
+}
+
+function balancerTagAt(tt: XraySettingsValue, index: number): string {
+ const b = balancerList(tt)[index];
+ return typeof b?.tag === 'string' ? b.tag : '';
+}
+
+function ruleLabel(rule: RuleObject, index: number): string {
+ const tag = typeof rule.ruleTag === 'string' ? rule.ruleTag.trim() : '';
+ return tag || `#${index + 1}`;
+}
+
+/** Balancers whose selector is left empty once `removedOutbounds` are gone. */
+function balancersEmptiedBy(tt: XraySettingsValue, removedOutbounds: Set): string[] {
+ if (removedOutbounds.size === 0) return [];
+ const emptied: string[] = [];
+ for (const b of balancerList(tt)) {
+ const tag = typeof b?.tag === 'string' ? b.tag : '';
+ if (tag === '') continue;
+ const selector = Array.isArray(b?.selector) ? b.selector : [];
+ if (selector.length === 0) continue;
+ if (selector.every((s) => removedOutbounds.has(s))) emptied.push(tag);
+ }
+ return emptied;
+}
+
+/**
+ * Single source of truth for how a deletion affects one rule, shared by the
+ * preview (`ruleImpacts`) and the mutation (`applyCleanup`) so the two can never
+ * disagree. Returns null when the rule is untouched; otherwise `keeps` names the
+ * surviving destination, or is '' when none remains and the rule must be dropped.
+ */
+function classifyRule(
+ rule: RuleObject,
+ removedOutbounds: Set,
+ removedBalancers: Set,
+): { losesOut: boolean; losesBal: boolean; keeps: string } | null {
+ const out = typeof rule?.outboundTag === 'string' ? rule.outboundTag : '';
+ const bal = typeof rule?.balancerTag === 'string' ? rule.balancerTag : '';
+ const losesOut = out !== '' && removedOutbounds.has(out);
+ const losesBal = bal !== '' && removedBalancers.has(bal);
+ if (!losesOut && !losesBal) return null;
+ const keptOut = out !== '' && !losesOut ? out : '';
+ const keptBal = bal !== '' && !losesBal ? bal : '';
+ return { losesOut, losesBal, keeps: keptOut || keptBal };
+}
+
+function ruleImpacts(
+ tt: XraySettingsValue,
+ removedOutbounds: Set,
+ removedBalancers: Set,
+): RuleImpact[] {
+ const impacts: RuleImpact[] = [];
+ ruleList(tt).forEach((rule, index) => {
+ const verdict = classifyRule(rule, removedOutbounds, removedBalancers);
+ if (!verdict) return;
+ impacts.push(
+ verdict.keeps
+ ? { index, label: ruleLabel(rule, index), fate: 'modified', keeps: verdict.keeps }
+ : { index, label: ruleLabel(rule, index), fate: 'removed' },
+ );
+ });
+ return impacts;
+}
+
+function applyCleanup(
+ tt: XraySettingsValue,
+ removedOutbounds: Set,
+ removedBalancers: Set,
+): void {
+ if (tt.routing && Array.isArray(tt.routing.rules)) {
+ const next: RuleObject[] = [];
+ for (const rule of tt.routing.rules) {
+ const verdict = classifyRule(rule, removedOutbounds, removedBalancers);
+ if (!verdict) {
+ next.push(rule);
+ continue;
+ }
+ if (verdict.losesOut) delete rule.outboundTag;
+ if (verdict.losesBal) delete rule.balancerTag;
+ if (verdict.keeps) next.push(rule);
+ }
+ tt.routing.rules = next;
+ }
+
+ if (tt.routing && Array.isArray(tt.routing.balancers)) {
+ const survivors: BalancerObject[] = [];
+ for (const balancer of tt.routing.balancers) {
+ if (!balancer) continue;
+ if (removedBalancers.has(balancer.tag)) continue;
+ if (removedOutbounds.size > 0 && Array.isArray(balancer.selector)) {
+ balancer.selector = balancer.selector.filter((s) => !removedOutbounds.has(s));
+ }
+ if (typeof balancer.fallbackTag === 'string' && removedOutbounds.has(balancer.fallbackTag)) {
+ balancer.fallbackTag = '';
+ }
+ survivors.push(balancer);
+ }
+ tt.routing.balancers = survivors;
+ }
+
+ if (removedOutbounds.size > 0 && Array.isArray(tt.outbounds)) {
+ tt.outbounds = tt.outbounds.filter(
+ (o) => !(typeof o?.tag === 'string' && removedOutbounds.has(o.tag)),
+ );
+ for (const outbound of tt.outbounds) {
+ const sockopt = (outbound as { streamSettings?: { sockopt?: { dialerProxy?: string } } })
+ ?.streamSettings?.sockopt;
+ if (sockopt && typeof sockopt.dialerProxy === 'string' && removedOutbounds.has(sockopt.dialerProxy)) {
+ delete sockopt.dialerProxy;
+ }
+ }
+ }
+
+ syncObservatories(tt);
+}
+
+function observersRemovedBy(
+ tt: XraySettingsValue,
+ removedOutbounds: Set,
+ removedBalancers: Set,
+): { observatory: boolean; burst: boolean } {
+ const hadObservatory = !!tt.observatory;
+ const hadBurst = !!tt.burstObservatory;
+ if (!hadObservatory && !hadBurst) return { observatory: false, burst: false };
+ const clone = JSON.parse(JSON.stringify(tt)) as XraySettingsValue;
+ applyCleanup(clone, removedOutbounds, removedBalancers);
+ return {
+ observatory: hadObservatory && !clone.observatory,
+ burst: hadBurst && !clone.burstObservatory,
+ };
+}
+
+export function planBalancerDeletion(tt: XraySettingsValue, index: number): DeletionImpact {
+ const tag = balancerTagAt(tt, index);
+ if (!tag) return emptyImpact();
+ const removedOutbounds = new Set();
+ const removedBalancers = new Set([tag]);
+ const obs = observersRemovedBy(tt, removedOutbounds, removedBalancers);
+ return {
+ rules: ruleImpacts(tt, removedOutbounds, removedBalancers),
+ balancers: [],
+ observatory: obs.observatory,
+ burst: obs.burst,
+ };
+}
+
+export function applyBalancerDeletion(tt: XraySettingsValue, index: number): void {
+ const tag = balancerTagAt(tt, index);
+ if (!tag) {
+ if (tt.routing && Array.isArray(tt.routing.balancers)) tt.routing.balancers.splice(index, 1);
+ syncObservatories(tt);
+ return;
+ }
+ applyCleanup(tt, new Set(), new Set([tag]));
+}
+
+export function planOutboundDeletion(tt: XraySettingsValue, index: number): DeletionImpact {
+ const tag = outboundTagAt(tt, index);
+ if (!tag) return emptyImpact();
+ const removedOutbounds = new Set([tag]);
+ const cascaded = balancersEmptiedBy(tt, removedOutbounds);
+ const removedBalancers = new Set(cascaded);
+ const obs = observersRemovedBy(tt, removedOutbounds, removedBalancers);
+ return {
+ rules: ruleImpacts(tt, removedOutbounds, removedBalancers),
+ balancers: cascaded.map((bTag) => ({ tag: bTag, reason: 'selectorEmptied' as const })),
+ observatory: obs.observatory,
+ burst: obs.burst,
+ };
+}
+
+export function applyOutboundDeletion(tt: XraySettingsValue, index: number): void {
+ const tag = outboundTagAt(tt, index);
+ if (!tag) {
+ if (Array.isArray(tt.outbounds)) tt.outbounds.splice(index, 1);
+ syncObservatories(tt);
+ return;
+ }
+ const removedOutbounds = new Set([tag]);
+ const removedBalancers = new Set(balancersEmptiedBy(tt, removedOutbounds));
+ applyCleanup(tt, removedOutbounds, removedBalancers);
+}
diff --git a/frontend/src/test/balancer-observatory-sync.test.ts b/frontend/src/test/balancer-observatory-sync.test.ts
index f4c00dcc2..40cfd4af9 100644
--- a/frontend/src/test/balancer-observatory-sync.test.ts
+++ b/frontend/src/test/balancer-observatory-sync.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { observersRemovedByDeletingBalancer, syncObservatories } from '@/pages/xray/balancers/balancer-helpers';
+import { syncObservatories } from '@/pages/xray/balancers/balancer-helpers';
import type { XraySettingsValue } from '@/hooks/useXraySetting';
function tpl(routing: Record, extra: Record = {}): XraySettingsValue {
@@ -138,42 +138,3 @@ describe('syncObservatories', () => {
);
});
});
-
-describe('observersRemovedByDeletingBalancer', () => {
- it('reports the burst observer as removed when deleting the last leastLoad balancer', () => {
- const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
- syncObservatories(t);
- expect(observersRemovedByDeletingBalancer(t, 0)).toEqual({ observatory: false, burst: true });
- });
-
- it('keeps the burst observer when another balancer still needs it (overlap)', () => {
- const t = tpl({
- balancers: [
- { tag: 'a', selector: ['prefixA', 'prefixB'], strategy: { type: 'leastLoad' } },
- { tag: 'b', selector: ['prefixC', 'prefixB'], strategy: { type: 'leastLoad' } },
- ],
- });
- syncObservatories(t);
- expect(observersRemovedByDeletingBalancer(t, 0)).toEqual({ observatory: false, burst: false });
- });
-
- it('reports the regular observer as removed when deleting the last leastPing balancer', () => {
- const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastPing' } }] });
- syncObservatories(t);
- expect(observersRemovedByDeletingBalancer(t, 0)).toEqual({ observatory: true, burst: false });
- });
-
- it('reports nothing removed when the balancer never had an observer', () => {
- const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'] }] });
- syncObservatories(t);
- expect(observersRemovedByDeletingBalancer(t, 0)).toEqual({ observatory: false, burst: false });
- });
-
- it('does not mutate the template it inspects', () => {
- const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
- syncObservatories(t);
- const before = JSON.stringify(t);
- observersRemovedByDeletingBalancer(t, 0);
- expect(JSON.stringify(t)).toBe(before);
- });
-});
diff --git a/frontend/src/test/routing-reference-cleanup.test.ts b/frontend/src/test/routing-reference-cleanup.test.ts
new file mode 100644
index 000000000..277bd5ea0
--- /dev/null
+++ b/frontend/src/test/routing-reference-cleanup.test.ts
@@ -0,0 +1,264 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ applyBalancerDeletion,
+ applyOutboundDeletion,
+ planBalancerDeletion,
+ planOutboundDeletion,
+} from '@/pages/xray/reference-cleanup';
+import type { XraySettingsValue } from '@/hooks/useXraySetting';
+
+function tpl(parts: Record): XraySettingsValue {
+ return parts as unknown as XraySettingsValue;
+}
+
+function dialerProxyOf(tt: XraySettingsValue, tag: string): string | undefined {
+ const o = tt.outbounds?.find((x) => x?.tag === tag);
+ return (o as { streamSettings?: { sockopt?: { dialerProxy?: string } } } | undefined)
+ ?.streamSettings?.sockopt?.dialerProxy;
+}
+
+describe('outbound deletion', () => {
+ it('drops a rule whose only destination was the deleted outbound', () => {
+ const tt = tpl({
+ outbounds: [{ tag: 'proxy-us' }],
+ routing: { rules: [{ type: 'field', inboundTag: ['in-443'], outboundTag: 'proxy-us' }], balancers: [] },
+ });
+ applyOutboundDeletion(tt, 0);
+ expect(tt.routing!.rules).toEqual([]);
+ expect(tt.outbounds).toEqual([]);
+ });
+
+ it('keeps a rule that still has a balancer, dropping only the dead outboundTag', () => {
+ const tt = tpl({
+ outbounds: [{ tag: 'proxy-us' }],
+ routing: {
+ rules: [{ type: 'field', outboundTag: 'proxy-us', balancerTag: 'eu-pool' }],
+ balancers: [{ tag: 'eu-pool', selector: ['direct'] }],
+ },
+ });
+ applyOutboundDeletion(tt, 0);
+ expect(tt.routing!.rules).toHaveLength(1);
+ expect(tt.routing!.rules![0].outboundTag).toBeUndefined();
+ expect(tt.routing!.rules![0].balancerTag).toBe('eu-pool');
+ });
+
+ it('reduces a multi-target selector and leaves the balancer and its rules intact', () => {
+ const tt = tpl({
+ outbounds: [{ tag: 'proxy-us' }, { tag: 'proxy-uk' }],
+ routing: {
+ rules: [{ type: 'field', inboundTag: ['in'], balancerTag: 'pool' }],
+ balancers: [{ tag: 'pool', selector: ['proxy-us', 'proxy-uk'] }],
+ },
+ });
+ applyOutboundDeletion(tt, 0);
+ expect(tt.routing!.balancers![0].selector).toEqual(['proxy-uk']);
+ expect(tt.routing!.rules).toHaveLength(1);
+ expect(tt.routing!.rules![0].balancerTag).toBe('pool');
+ expect((tt.outbounds || []).map((o) => o?.tag)).toEqual(['proxy-uk']);
+ });
+
+ it('cascade-removes a balancer whose selector is emptied, repairing its rules', () => {
+ const tt = tpl({
+ outbounds: [{ tag: 'proxy-us' }],
+ routing: {
+ rules: [
+ { type: 'field', inboundTag: ['in'], balancerTag: 'pool' },
+ { type: 'field', outboundTag: 'direct', balancerTag: 'pool' },
+ ],
+ balancers: [{ tag: 'pool', selector: ['proxy-us'] }],
+ },
+ });
+ const impact = planOutboundDeletion(tt, 0);
+ expect(impact.balancers).toEqual([{ tag: 'pool', reason: 'selectorEmptied' }]);
+ expect(impact.rules).toEqual([
+ { index: 0, label: '#1', fate: 'removed' },
+ { index: 1, label: '#2', fate: 'modified', keeps: 'direct' },
+ ]);
+
+ applyOutboundDeletion(tt, 0);
+ expect(tt.routing!.balancers).toEqual([]);
+ expect(tt.routing!.rules).toHaveLength(1);
+ expect(tt.routing!.rules![0].outboundTag).toBe('direct');
+ expect(tt.routing!.rules![0].balancerTag).toBeUndefined();
+ });
+
+ it('clears a fallbackTag and a dialerProxy pointing at the deleted outbound', () => {
+ const tt = tpl({
+ outbounds: [
+ { tag: 'proxy-us' },
+ { tag: 'chain', streamSettings: { sockopt: { dialerProxy: 'proxy-us' } } },
+ ],
+ routing: {
+ rules: [],
+ balancers: [{ tag: 'pool', selector: ['proxy-us', 'proxy-uk'], fallbackTag: 'proxy-us' }],
+ },
+ });
+ applyOutboundDeletion(tt, 0);
+ expect(tt.routing!.balancers![0].selector).toEqual(['proxy-uk']);
+ expect(tt.routing!.balancers![0].fallbackTag).toBe('');
+ expect(dialerProxyOf(tt, 'chain')).toBeUndefined();
+ });
+
+ it('never cascade-removes a tagless balancer (an empty tag must not match others)', () => {
+ const tt = tpl({
+ outbounds: [{ tag: 'proxy-us' }],
+ routing: {
+ rules: [],
+ balancers: [
+ { tag: '', selector: ['proxy-us'] },
+ { tag: '', selector: ['keep-me'] },
+ ],
+ },
+ });
+ applyOutboundDeletion(tt, 0);
+ expect(tt.routing!.balancers).toHaveLength(2);
+ });
+
+ it('does not throw on null entries in rules/balancers/outbounds (unvalidated config)', () => {
+ const tt = tpl({
+ outbounds: [{ tag: 'proxy-us' }, null],
+ routing: {
+ rules: [null, { type: 'field', inboundTag: ['in'], outboundTag: 'proxy-us' }],
+ balancers: [null, { tag: 'pool', selector: ['keep'] }],
+ },
+ });
+ expect(() => planOutboundDeletion(tt, 0)).not.toThrow();
+ expect(() => applyOutboundDeletion(tt, 0)).not.toThrow();
+ expect(tt.routing!.balancers).toEqual([{ tag: 'pool', selector: ['keep'] }]);
+ });
+
+ it('drops a rule that loses BOTH destinations in one cascade', () => {
+ const tt = tpl({
+ outbounds: [{ tag: 'proxy-us' }],
+ routing: {
+ rules: [{ type: 'field', outboundTag: 'proxy-us', balancerTag: 'pool' }],
+ balancers: [{ tag: 'pool', selector: ['proxy-us'] }],
+ },
+ });
+ applyOutboundDeletion(tt, 0);
+ expect(tt.routing!.rules).toEqual([]);
+ expect(tt.routing!.balancers).toEqual([]);
+ });
+
+ it('cleans a disabled rule too', () => {
+ const tt = tpl({
+ outbounds: [{ tag: 'proxy-us' }],
+ routing: {
+ rules: [{ type: 'field', enabled: false, inboundTag: ['in'], outboundTag: 'proxy-us' }],
+ balancers: [],
+ },
+ });
+ applyOutboundDeletion(tt, 0);
+ expect(tt.routing!.rules).toEqual([]);
+ });
+
+ it('leaves unrelated rules and outbounds untouched', () => {
+ const tt = tpl({
+ outbounds: [{ tag: 'proxy-us' }, { tag: 'direct' }],
+ routing: {
+ rules: [
+ { type: 'field', inboundTag: ['in'], outboundTag: 'proxy-us' },
+ { type: 'field', inboundTag: ['in2'], outboundTag: 'direct' },
+ ],
+ balancers: [],
+ },
+ });
+ applyOutboundDeletion(tt, 0);
+ expect(tt.routing!.rules).toHaveLength(1);
+ expect(tt.routing!.rules![0].outboundTag).toBe('direct');
+ expect((tt.outbounds || []).map((o) => o?.tag)).toEqual(['direct']);
+ });
+
+ it('removes a referenced outbound with no rules and reports an empty impact', () => {
+ const tt = tpl({ outbounds: [{ tag: 'lonely' }], routing: { rules: [], balancers: [] } });
+ expect(planOutboundDeletion(tt, 0)).toEqual({ rules: [], balancers: [], observatory: false, burst: false });
+ applyOutboundDeletion(tt, 0);
+ expect(tt.outbounds).toEqual([]);
+ });
+
+ it('uses ruleTag as the impact label when present', () => {
+ const tt = tpl({
+ outbounds: [{ tag: 'x' }],
+ routing: { rules: [{ type: 'field', ruleTag: 'block-ads', outboundTag: 'x' }], balancers: [] },
+ });
+ expect(planOutboundDeletion(tt, 0).rules[0].label).toBe('block-ads');
+ });
+
+ it('does not mutate the template when only planning', () => {
+ const tt = tpl({
+ outbounds: [{ tag: 'proxy-us' }],
+ routing: {
+ rules: [{ type: 'field', outboundTag: 'proxy-us', balancerTag: 'pool' }],
+ balancers: [{ tag: 'pool', selector: ['proxy-us'] }],
+ },
+ burstObservatory: { subjectSelector: ['proxy-us'] },
+ });
+ const before = JSON.stringify(tt);
+ planOutboundDeletion(tt, 0);
+ expect(JSON.stringify(tt)).toBe(before);
+ });
+
+ it('predicts the surviving rule count exactly (plan/apply parity)', () => {
+ const make = () =>
+ tpl({
+ outbounds: [{ tag: 'proxy-us' }],
+ routing: {
+ rules: [
+ { type: 'field', inboundTag: ['a'], outboundTag: 'proxy-us' },
+ { type: 'field', outboundTag: 'proxy-us', balancerTag: 'pool' },
+ { type: 'field', inboundTag: ['b'], outboundTag: 'direct' },
+ { type: 'field', inboundTag: ['c'], balancerTag: 'pool' },
+ ],
+ balancers: [{ tag: 'pool', selector: ['proxy-us'] }],
+ },
+ });
+ const planned = make();
+ const applied = make();
+ const total = planned.routing!.rules!.length;
+ const removed = planOutboundDeletion(planned, 0).rules.filter((r) => r.fate === 'removed').length;
+ applyOutboundDeletion(applied, 0);
+ expect(applied.routing!.rules!.length).toBe(total - removed);
+ });
+});
+
+describe('balancer deletion', () => {
+ it('drops a rule whose only destination was the deleted balancer', () => {
+ const tt = tpl({
+ routing: { rules: [{ type: 'field', inboundTag: ['in'], balancerTag: 'pool' }], balancers: [{ tag: 'pool', selector: ['a'] }] },
+ });
+ applyBalancerDeletion(tt, 0);
+ expect(tt.routing!.balancers).toEqual([]);
+ expect(tt.routing!.rules).toEqual([]);
+ });
+
+ it('keeps a rule that still has an outbound, dropping only the dead balancerTag', () => {
+ const tt = tpl({
+ routing: { rules: [{ type: 'field', outboundTag: 'direct', balancerTag: 'pool' }], balancers: [{ tag: 'pool', selector: ['a'] }] },
+ });
+ applyBalancerDeletion(tt, 0);
+ expect(tt.routing!.rules).toHaveLength(1);
+ expect(tt.routing!.rules![0].balancerTag).toBeUndefined();
+ expect(tt.routing!.rules![0].outboundTag).toBe('direct');
+ });
+
+ it('reports and removes the observer when deleting the last leastPing balancer', () => {
+ const tt = tpl({
+ routing: { rules: [], balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastPing' } }] },
+ observatory: { subjectSelector: ['a'] },
+ });
+ expect(planBalancerDeletion(tt, 0).observatory).toBe(true);
+ applyBalancerDeletion(tt, 0);
+ expect(tt.observatory).toBeUndefined();
+ expect(tt.routing!.balancers).toEqual([]);
+ });
+
+ it('does not report rules when the deleted balancer is unreferenced', () => {
+ const tt = tpl({
+ routing: { rules: [{ type: 'field', inboundTag: ['in'], outboundTag: 'direct' }], balancers: [{ tag: 'pool', selector: ['a'] }] },
+ });
+ expect(planBalancerDeletion(tt, 0).rules).toEqual([]);
+ applyBalancerDeletion(tt, 0);
+ expect(tt.routing!.rules).toHaveLength(1);
+ });
+});
diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json
index 5feaf5f88..5ede9ede8 100644
--- a/internal/web/translation/ar-EG.json
+++ b/internal/web/translation/ar-EG.json
@@ -1709,6 +1709,12 @@
"deleteAlsoObservatory": "هذا آخر موازن يستخدم Observatory، لذلك ستتم إزالته أيضًا.",
"deleteAlsoBurst": "هذا آخر موازن يستخدم Burst Observatory، لذلك ستتم إزالته أيضًا."
},
+ "refCleanup": {
+ "header": "حذف هذا سيُحدِّث التوجيه أيضًا:",
+ "ruleRemoved": "القاعدة {label} — أُزيلت (لا توجد وجهة متبقية)",
+ "ruleModified": "القاعدة {label} — مُحتفَظ بها (تستخدم الآن {keeps})",
+ "balancerRemoved": "الموازن {tag} — أُزيل (لا توجد أهداف متبقية)"
+ },
"balancer": {
"addBalancer": "أضف موازن تحميل",
"editBalancer": "عدل موازن التحميل",
diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json
index fbd228491..8e02f8df8 100644
--- a/internal/web/translation/en-US.json
+++ b/internal/web/translation/en-US.json
@@ -1825,6 +1825,12 @@
"deleteAlsoObservatory": "This is the last balancer using the Observatory, so it will be removed too.",
"deleteAlsoBurst": "This is the last balancer using the Burst Observatory, so it will be removed too."
},
+ "refCleanup": {
+ "header": "Deleting this also updates your routing:",
+ "ruleRemoved": "Rule {label} — removed (no destination left)",
+ "ruleModified": "Rule {label} — kept (now uses {keeps})",
+ "balancerRemoved": "Balancer {tag} — removed (no targets left)"
+ },
"balancer": {
"addBalancer": "Add Balancer",
"editBalancer": "Edit Balancer",
diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json
index c8b812bd6..9eef966fd 100644
--- a/internal/web/translation/es-ES.json
+++ b/internal/web/translation/es-ES.json
@@ -1709,6 +1709,12 @@
"deleteAlsoObservatory": "Este es el último balanceador que usa el Observatorio, por lo que también se eliminará.",
"deleteAlsoBurst": "Este es el último balanceador que usa el Observatorio Burst, por lo que también se eliminará."
},
+ "refCleanup": {
+ "header": "Al eliminar esto también se actualiza tu enrutamiento:",
+ "ruleRemoved": "Regla {label} — eliminada (sin destino restante)",
+ "ruleModified": "Regla {label} — conservada (ahora usa {keeps})",
+ "balancerRemoved": "Balanceador {tag} — eliminado (sin destinos restantes)"
+ },
"balancer": {
"addBalancer": "Agregar equilibrador",
"editBalancer": "Editar balanceador",
diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json
index d1feb690d..2f10f7776 100644
--- a/internal/web/translation/fa-IR.json
+++ b/internal/web/translation/fa-IR.json
@@ -1709,6 +1709,12 @@
"deleteAlsoObservatory": "این آخرین بالانسری است که از Observatory استفاده میکند، بنابراین آن هم حذف خواهد شد.",
"deleteAlsoBurst": "این آخرین بالانسری است که از Burst Observatory استفاده میکند، بنابراین آن هم حذف خواهد شد."
},
+ "refCleanup": {
+ "header": "حذف این مورد مسیریابی شما را هم بهروز میکند:",
+ "ruleRemoved": "قاعده {label} — حذف شد (مقصدی باقی نماند)",
+ "ruleModified": "قاعده {label} — حفظ شد (اکنون از {keeps} استفاده میکند)",
+ "balancerRemoved": "بالانسر {tag} — حذف شد (هدفی باقی نماند)"
+ },
"balancer": {
"addBalancer": "افزودن بالانسر",
"editBalancer": "ویرایش بالانسر",
diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json
index 36711af06..ce9e8ac0c 100644
--- a/internal/web/translation/id-ID.json
+++ b/internal/web/translation/id-ID.json
@@ -1709,6 +1709,12 @@
"deleteAlsoObservatory": "Ini balancer terakhir yang memakai Observatory, jadi itu juga akan dihapus.",
"deleteAlsoBurst": "Ini balancer terakhir yang memakai Burst Observatory, jadi itu juga akan dihapus."
},
+ "refCleanup": {
+ "header": "Menghapus ini juga memperbarui perutean Anda:",
+ "ruleRemoved": "Aturan {label} — dihapus (tidak ada tujuan tersisa)",
+ "ruleModified": "Aturan {label} — dipertahankan (kini memakai {keeps})",
+ "balancerRemoved": "Balancer {tag} — dihapus (tidak ada target tersisa)"
+ },
"balancer": {
"addBalancer": "Tambahkan Penyeimbang",
"editBalancer": "Sunting Penyeimbang",
diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json
index 8e6d87885..a00f9a5fd 100644
--- a/internal/web/translation/ja-JP.json
+++ b/internal/web/translation/ja-JP.json
@@ -1709,6 +1709,12 @@
"deleteAlsoObservatory": "これは Observatory を使用する最後のバランサーのため、こちらも削除されます。",
"deleteAlsoBurst": "これは Burst Observatory を使用する最後のバランサーのため、こちらも削除されます。"
},
+ "refCleanup": {
+ "header": "これを削除するとルーティングも更新されます:",
+ "ruleRemoved": "ルール {label} — 削除(送信先が残っていません)",
+ "ruleModified": "ルール {label} — 保持(現在は {keeps} を使用)",
+ "balancerRemoved": "バランサー {tag} — 削除(対象が残っていません)"
+ },
"balancer": {
"addBalancer": "負荷分散追加",
"editBalancer": "負荷分散編集",
diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json
index 90bcad1a8..68f16ea22 100644
--- a/internal/web/translation/pt-BR.json
+++ b/internal/web/translation/pt-BR.json
@@ -1709,6 +1709,12 @@
"deleteAlsoObservatory": "Este é o último balanceador que usa o Observatório, então ele também será removido.",
"deleteAlsoBurst": "Este é o último balanceador que usa o Observatório Burst, então ele também será removido."
},
+ "refCleanup": {
+ "header": "Excluir isto também atualiza o seu roteamento:",
+ "ruleRemoved": "Regra {label} — removida (sem destino restante)",
+ "ruleModified": "Regra {label} — mantida (agora usa {keeps})",
+ "balancerRemoved": "Balanceador {tag} — removido (sem destinos restantes)"
+ },
"balancer": {
"addBalancer": "Adicionar Balanceador",
"editBalancer": "Editar Balanceador",
diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json
index b7267e4eb..405f58693 100644
--- a/internal/web/translation/ru-RU.json
+++ b/internal/web/translation/ru-RU.json
@@ -1709,6 +1709,12 @@
"deleteAlsoObservatory": "Это последний балансировщик, использующий Observatory, поэтому он тоже будет удалён.",
"deleteAlsoBurst": "Это последний балансировщик, использующий Burst Observatory, поэтому он тоже будет удалён."
},
+ "refCleanup": {
+ "header": "Удаление также обновит маршрутизацию:",
+ "ruleRemoved": "Правило {label} — удалено (не осталось назначения)",
+ "ruleModified": "Правило {label} — сохранено (теперь использует {keeps})",
+ "balancerRemoved": "Балансировщик {tag} — удалён (не осталось целей)"
+ },
"balancer": {
"addBalancer": "Создать балансировщик",
"editBalancer": "Редактировать балансировщик",
diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json
index fc4b5a88b..6791bf8c1 100644
--- a/internal/web/translation/tr-TR.json
+++ b/internal/web/translation/tr-TR.json
@@ -1709,6 +1709,12 @@
"deleteAlsoObservatory": "Bu, Observatory kullanan son dengeleyici, bu yüzden o da kaldırılacak.",
"deleteAlsoBurst": "Bu, Burst Observatory kullanan son dengeleyici, bu yüzden o da kaldırılacak."
},
+ "refCleanup": {
+ "header": "Bunu silmek yönlendirmenizi de günceller:",
+ "ruleRemoved": "Kural {label} — kaldırıldı (hedef kalmadı)",
+ "ruleModified": "Kural {label} — korundu (artık {keeps} kullanıyor)",
+ "balancerRemoved": "Dengeleyici {tag} — kaldırıldı (hedef kalmadı)"
+ },
"balancer": {
"addBalancer": "Dengeleyici Ekle",
"editBalancer": "Dengeleyiciyi Düzenle",
diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json
index d41857638..23567981b 100644
--- a/internal/web/translation/uk-UA.json
+++ b/internal/web/translation/uk-UA.json
@@ -1709,6 +1709,12 @@
"deleteAlsoObservatory": "Це останній балансувальник, що використовує Observatory, тож його теж буде видалено.",
"deleteAlsoBurst": "Це останній балансувальник, що використовує Burst Observatory, тож його теж буде видалено."
},
+ "refCleanup": {
+ "header": "Видалення також оновить маршрутизацію:",
+ "ruleRemoved": "Правило {label} — видалено (не залишилося призначення)",
+ "ruleModified": "Правило {label} — збережено (тепер використовує {keeps})",
+ "balancerRemoved": "Балансувальник {tag} — видалено (не залишилося цілей)"
+ },
"balancer": {
"addBalancer": "Додати балансир",
"editBalancer": "Редагувати балансир",
diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json
index c411f1988..e3241f09c 100644
--- a/internal/web/translation/vi-VN.json
+++ b/internal/web/translation/vi-VN.json
@@ -1709,6 +1709,12 @@
"deleteAlsoObservatory": "Đây là balancer cuối cùng dùng Observatory, nên nó cũng sẽ bị xóa.",
"deleteAlsoBurst": "Đây là balancer cuối cùng dùng Burst Observatory, nên nó cũng sẽ bị xóa."
},
+ "refCleanup": {
+ "header": "Xóa mục này cũng cập nhật định tuyến của bạn:",
+ "ruleRemoved": "Quy tắc {label} — đã xóa (không còn đích đến)",
+ "ruleModified": "Quy tắc {label} — giữ lại (giờ dùng {keeps})",
+ "balancerRemoved": "Balancer {tag} — đã xóa (không còn mục tiêu)"
+ },
"balancer": {
"addBalancer": "Thêm cân bằng",
"editBalancer": "Chỉnh sửa cân bằng",
diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json
index 6fd41ba8b..1679f66c1 100644
--- a/internal/web/translation/zh-CN.json
+++ b/internal/web/translation/zh-CN.json
@@ -1709,6 +1709,12 @@
"deleteAlsoObservatory": "这是最后一个使用 Observatory 的负载均衡器,因此它也会被一并移除。",
"deleteAlsoBurst": "这是最后一个使用 Burst Observatory 的负载均衡器,因此它也会被一并移除。"
},
+ "refCleanup": {
+ "header": "删除此项还会更新你的路由:",
+ "ruleRemoved": "规则 {label} — 已移除(没有剩余出口)",
+ "ruleModified": "规则 {label} — 已保留(现使用 {keeps})",
+ "balancerRemoved": "负载均衡器 {tag} — 已移除(没有剩余目标)"
+ },
"balancer": {
"addBalancer": "添加负载均衡",
"editBalancer": "编辑负载均衡",
diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json
index 3c119b69e..5ffafa843 100644
--- a/internal/web/translation/zh-TW.json
+++ b/internal/web/translation/zh-TW.json
@@ -1709,6 +1709,12 @@
"deleteAlsoObservatory": "這是最後一個使用 Observatory 的負載平衡器,因此它也會一併被移除。",
"deleteAlsoBurst": "這是最後一個使用 Burst Observatory 的負載平衡器,因此它也會一併被移除。"
},
+ "refCleanup": {
+ "header": "刪除此項也會更新你的路由:",
+ "ruleRemoved": "規則 {label} — 已移除(沒有剩餘出口)",
+ "ruleModified": "規則 {label} — 已保留(現使用 {keeps})",
+ "balancerRemoved": "負載平衡器 {tag} — 已移除(沒有剩餘目標)"
+ },
"balancer": {
"addBalancer": "新增負載均衡",
"editBalancer": "編輯負載均衡",