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')}

+ +
+ ); +} 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": "編輯負載均衡",