Browse Source

fix(xray): clean stale routing references when a balancer or outbound is deleted (#5648)

* feat(xray): reference-cleanup helpers for entity deletion

When an outbound or balancer is deleted on the Xray page, routing rules and
balancers that reference it must be repaired in the same edit, or the saved
config breaks the core: a dangling balancerTag stops Router.Init (whole core
down), a dangling outboundTag black-holes matched traffic at the dispatcher.

Add pure plan*/apply* helpers that compute and apply the cleanup. A rule is
kept when a destination (outboundTag or balancerTag) remains and dropped when
none does. Deleting an outbound cascades: emptying a balancer selector removes
that balancer too, then repairs its rules in one pass against the full removed
set; fallbackTag and dialerProxy references are cleared and observatories
re-synced.

* fix(balancers): clean routing rules referencing a deleted balancer

Deleting a balancer left routing rules pointing at its balancerTag. xray-core's
Router.Init then fails ("balancer <tag> not found"), the core won't restart and
every inbound drops — the saved config passes CheckXrayConfig (JSON shape only),
so it breaks only on the next restart.

The delete confirm now lists the affected rules (modified vs removed) next to
the existing observatory warning and applies planBalancerDeletion's cleanup: a
rule keeps its outboundTag when present, otherwise the whole rule is dropped.
Adds the shared DeletionImpactList and refCleanup strings across all 13 locales.

* fix(outbounds): clean rules, balancer selectors and dialerProxy on outbound delete

Deleting an outbound left routing rules pointing at its outboundTag (matched
traffic black-holed at the dispatcher), plus stale references in balancer
selectors / fallbackTag and other outbounds' dialerProxy.

The delete confirm now shows planOutboundDeletion's impact and applies the
cascade: rules keep a remaining balancerTag (else are dropped), the tag is
pulled from balancer selectors and fallbacks, dialerProxy references are
cleared, and a balancer whose selector is emptied is removed along with its
own now-targetless rules.

* refactor(xray): share one rule classifier across preview and apply

Code review flagged that the keep/drop predicate was transcribed twice — in
ruleImpacts (the delete-modal preview) and in applyCleanup (the mutation) — kept
in sync only by a parity test. Extract a single classifyRule() that both call,
so the preview can never disagree with what apply actually does.

Also harden balancersEmptiedBy to skip tagless balancers: an empty/missing tag
would otherwise enter the removed set as "" and silently drop every other
tagless balancer (only reachable via a hand-edited config, but a silent data
loss). And remove observersRemovedByDeletingBalancer, orphaned once BalancersTab
switched to planBalancerDeletion.

* fix(xray): null-guard reference cleanup against unvalidated configs

The PR review noted that classifyRule and applyCleanup dereferenced rule /
balancer entries directly, while the sibling propagateOutboundTagRename uses
optional chaining — because fetchXrayConfig falls back to the unvalidated parsed
object when Zod validation fails, a stray null in rules / balancers can survive
into the editor and would throw during the delete preview/apply.

Match that defensive style: classifyRule and balancersEmptiedBy read through
optional chaining, the balancer loop skips nullish entries, and the dialerProxy
walk guards the outbound. A delete on a hand-edited config with null entries now
degrades gracefully instead of throwing.
nima1024m 13 hours ago
parent
commit
7a5d6da28c

+ 40 - 0
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 (
+    <div>
+      <p style={{ marginBottom: 8 }}>{t('pages.xray.refCleanup.header')}</p>
+      <ul style={{ margin: 0, paddingInlineStart: 20 }}>
+        {lines.map((line, i) => (
+          <li key={i}>
+            <bdi>{line}</bdi>
+          </li>
+        ))}
+      </ul>
+    </div>
+  );
+}

+ 8 - 14
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: <DeletionImpactList impact={impact} />,
       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)),
     });
   }
 

+ 0 - 16
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,
-  };
-}

+ 7 - 5
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: <DeletionImpactList impact={impact} />,
       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) {

+ 236 - 0
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>): 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<string>,
+  removedBalancers: Set<string>,
+): { 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<string>,
+  removedBalancers: Set<string>,
+): 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<string>,
+  removedBalancers: Set<string>,
+): 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<string>,
+  removedBalancers: Set<string>,
+): { 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<string>();
+  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<string>(), 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);
+}

+ 1 - 40
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<string, unknown>, extra: Record<string, unknown> = {}): 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);
-  });
-});

+ 264 - 0
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<string, unknown>): 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);
+  });
+});

+ 6 - 0
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": "عدل موازن التحميل",

+ 6 - 0
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",

+ 6 - 0
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",

+ 6 - 0
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": "ویرایش بالانسر",

+ 6 - 0
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",

+ 6 - 0
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": "負荷分散編集",

+ 6 - 0
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",

+ 6 - 0
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": "Редактировать балансировщик",

+ 6 - 0
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",

+ 6 - 0
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": "Редагувати балансир",

+ 6 - 0
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",

+ 6 - 0
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": "编辑负载均衡",

+ 6 - 0
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": "編輯負載均衡",