CI / go-test (push) Canceled after 0s
CI / postgres-durable-first (push) Canceled after 0s
CI / codegen (push) Canceled after 0s
CI / govulncheck (push) Canceled after 0s
CI / race (push) Canceled after 0s
CI / fuzz-smoke (push) Canceled after 0s
CI / golangci (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CodeQL Advanced / Analyze (go) (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
Docs CI / build (push) Canceled after 0s
Docs Deploy (GitHub Pages) / build (push) Canceled after 0s
Release 3X-UI / build (386) (push) Canceled after 0s
Release 3X-UI / build (amd64) (push) Canceled after 0s
Release 3X-UI / build (arm64) (push) Canceled after 0s
Release 3X-UI / build (armv5) (push) Canceled after 0s
Release 3X-UI / build (armv6) (push) Canceled after 0s
Release 3X-UI / build (armv7) (push) Canceled after 0s
Release 3X-UI / build (s390x) (push) Canceled after 0s
Release 3X-UI / Build for Windows (amd64) (push) Canceled after 0s
Deploy Smoke Tests / noninteractive-install (ubuntu-24.04-arm) (push) Canceled after 0s
Deploy Smoke Tests / noninteractive-install (ubuntu-latest) (push) Canceled after 0s
Deploy Smoke Tests / release-tag-install (ubuntu-24.04-arm) (push) Canceled after 0s
Deploy Smoke Tests / release-tag-install (ubuntu-latest) (push) Canceled after 0s
Docs Deploy (GitHub Pages) / deploy (push) Canceled after 0s
Release 3X-UI / Publish rolling dev release (push) Canceled after 0s
265 lines
8.0 KiB
TypeScript
265 lines
8.0 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import {
|
|
buildRoutingJson,
|
|
type DomainStrategy,
|
|
type RoutingInput,
|
|
type RuleNetwork,
|
|
type Strategy,
|
|
} from '@/lib/xray/routing';
|
|
import { ToolFrame } from './tool-frame';
|
|
import { TextField, SelectField } from './shared/fields';
|
|
import { OutputBlock } from './shared/output-block';
|
|
|
|
interface BalancerRow {
|
|
tag: string;
|
|
selector: string;
|
|
strategy: Strategy;
|
|
fallbackTag: string;
|
|
}
|
|
|
|
interface RuleRow {
|
|
domain: string;
|
|
ip: string;
|
|
port: string;
|
|
network: string;
|
|
inboundTag: string;
|
|
targetKind: 'outbound' | 'balancer';
|
|
targetTag: string;
|
|
}
|
|
|
|
const STRATEGIES: readonly Strategy[] = ['random', 'roundRobin', 'leastPing', 'leastLoad'];
|
|
const NETWORKS = ['any', 'tcp', 'udp', 'tcp,udp'];
|
|
const TARGET_KINDS = ['outbound', 'balancer'];
|
|
const DOMAIN_STRATEGIES: readonly DomainStrategy[] = ['AsIs', 'IPIfNonMatch', 'IPOnDemand'];
|
|
|
|
const DEFAULT_BALANCERS: BalancerRow[] = [
|
|
{ tag: 'balancer', selector: 'proxy', strategy: 'leastPing', fallbackTag: '' },
|
|
];
|
|
const DEFAULT_RULES: RuleRow[] = [
|
|
{
|
|
domain: 'geosite:category-ads-all',
|
|
ip: '',
|
|
port: '',
|
|
network: 'any',
|
|
inboundTag: '',
|
|
targetKind: 'outbound',
|
|
targetTag: 'block',
|
|
},
|
|
{
|
|
domain: '',
|
|
ip: 'geoip:private',
|
|
port: '',
|
|
network: 'any',
|
|
inboundTag: '',
|
|
targetKind: 'outbound',
|
|
targetTag: 'direct',
|
|
},
|
|
];
|
|
|
|
function list(s: string): string[] {
|
|
return s
|
|
.split(',')
|
|
.map((x) => x.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
const addBtn =
|
|
'inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-medium transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground';
|
|
|
|
export function RoutingBuilder() {
|
|
const [domainStrategy, setDomainStrategy] = useState<DomainStrategy>('IPIfNonMatch');
|
|
const [balancers, setBalancers] = useState<BalancerRow[]>(DEFAULT_BALANCERS);
|
|
const [rules, setRules] = useState<RuleRow[]>(DEFAULT_RULES);
|
|
|
|
function patchBalancer(i: number, patch: Partial<BalancerRow>) {
|
|
setBalancers((prev) => prev.map((b, j) => (i === j ? { ...b, ...patch } : b)));
|
|
}
|
|
function patchRule(i: number, patch: Partial<RuleRow>) {
|
|
setRules((prev) => prev.map((r, j) => (i === j ? { ...r, ...patch } : r)));
|
|
}
|
|
|
|
const input: RoutingInput = {
|
|
domainStrategy,
|
|
balancers: balancers
|
|
.filter((b) => b.tag.trim())
|
|
.map((b) => ({
|
|
tag: b.tag.trim(),
|
|
selector: list(b.selector),
|
|
strategy: b.strategy,
|
|
fallbackTag: b.fallbackTag.trim() || undefined,
|
|
})),
|
|
rules: rules
|
|
.filter((r) => r.targetTag.trim())
|
|
.map((r) => ({
|
|
domain: list(r.domain),
|
|
ip: list(r.ip),
|
|
port: r.port.trim() || undefined,
|
|
network: r.network === 'any' ? undefined : (r.network as RuleNetwork),
|
|
inboundTag: list(r.inboundTag),
|
|
target: { kind: r.targetKind, tag: r.targetTag.trim() },
|
|
})),
|
|
};
|
|
|
|
function reset() {
|
|
setDomainStrategy('IPIfNonMatch');
|
|
setBalancers(DEFAULT_BALANCERS);
|
|
setRules(DEFAULT_RULES);
|
|
}
|
|
|
|
return (
|
|
<ToolFrame
|
|
title="Balancer & routing builder"
|
|
description="Compose Xray balancers and routing rules, then copy the routing block (with a matching observatory for leastPing/leastLoad)."
|
|
onReset={reset}
|
|
>
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<SelectField
|
|
label="Domain strategy"
|
|
value={domainStrategy}
|
|
onChange={(v) => setDomainStrategy(v as DomainStrategy)}
|
|
options={DOMAIN_STRATEGIES}
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-5 flex items-center justify-between">
|
|
<h4 className="text-sm font-semibold">Balancers</h4>
|
|
<button
|
|
type="button"
|
|
className={addBtn}
|
|
onClick={() =>
|
|
setBalancers((p) => [
|
|
...p,
|
|
{ tag: '', selector: '', strategy: 'random', fallbackTag: '' },
|
|
])
|
|
}
|
|
>
|
|
Add balancer
|
|
</button>
|
|
</div>
|
|
<div className="mt-2 flex flex-col gap-3">
|
|
{balancers.map((b, i) => (
|
|
<div key={i} className="rounded-xl border p-3">
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
<TextField label="Tag" value={b.tag} onChange={(v) => patchBalancer(i, { tag: v })} />
|
|
<TextField
|
|
label="Selector (comma-separated prefixes)"
|
|
value={b.selector}
|
|
onChange={(v) => patchBalancer(i, { selector: v })}
|
|
/>
|
|
<SelectField
|
|
label="Strategy"
|
|
value={b.strategy}
|
|
onChange={(v) => patchBalancer(i, { strategy: v as Strategy })}
|
|
options={STRATEGIES}
|
|
/>
|
|
<TextField
|
|
label="Fallback tag"
|
|
value={b.fallbackTag}
|
|
onChange={(v) => patchBalancer(i, { fallbackTag: v })}
|
|
placeholder="optional"
|
|
/>
|
|
</div>
|
|
<div className="mt-2 flex justify-end">
|
|
<button
|
|
type="button"
|
|
className={addBtn}
|
|
onClick={() => setBalancers((p) => p.filter((_, j) => j !== i))}
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="mt-5 flex items-center justify-between">
|
|
<h4 className="text-sm font-semibold">Rules</h4>
|
|
<button
|
|
type="button"
|
|
className={addBtn}
|
|
onClick={() =>
|
|
setRules((p) => [
|
|
...p,
|
|
{
|
|
domain: '',
|
|
ip: '',
|
|
port: '',
|
|
network: 'any',
|
|
inboundTag: '',
|
|
targetKind: 'outbound',
|
|
targetTag: '',
|
|
},
|
|
])
|
|
}
|
|
>
|
|
Add rule
|
|
</button>
|
|
</div>
|
|
<div className="mt-2 flex flex-col gap-3">
|
|
{rules.map((r, i) => (
|
|
<div key={i} className="rounded-xl border p-3">
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
<TextField
|
|
label="Domain (comma)"
|
|
value={r.domain}
|
|
onChange={(v) => patchRule(i, { domain: v })}
|
|
placeholder="geosite:google, example.com"
|
|
/>
|
|
<TextField
|
|
label="IP (comma)"
|
|
value={r.ip}
|
|
onChange={(v) => patchRule(i, { ip: v })}
|
|
placeholder="geoip:cn, 1.1.1.1"
|
|
/>
|
|
<TextField
|
|
label="Port"
|
|
value={r.port}
|
|
onChange={(v) => patchRule(i, { port: v })}
|
|
placeholder="443 or 1000-2000"
|
|
/>
|
|
<SelectField
|
|
label="Network"
|
|
value={r.network}
|
|
onChange={(v) => patchRule(i, { network: v })}
|
|
options={NETWORKS}
|
|
/>
|
|
<TextField
|
|
label="Inbound tag (comma)"
|
|
value={r.inboundTag}
|
|
onChange={(v) => patchRule(i, { inboundTag: v })}
|
|
placeholder="optional"
|
|
/>
|
|
<SelectField
|
|
label="Target kind"
|
|
value={r.targetKind}
|
|
onChange={(v) => patchRule(i, { targetKind: v as 'outbound' | 'balancer' })}
|
|
options={TARGET_KINDS}
|
|
/>
|
|
<TextField
|
|
label="Target tag"
|
|
value={r.targetTag}
|
|
onChange={(v) => patchRule(i, { targetTag: v })}
|
|
/>
|
|
</div>
|
|
<div className="mt-2 flex justify-end">
|
|
<button
|
|
type="button"
|
|
className={addBtn}
|
|
onClick={() => setRules((p) => p.filter((_, j) => j !== i))}
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
<OutputBlock label="Routing block (Xray JSON)" value={buildRoutingJson(input)} />
|
|
</div>
|
|
</ToolFrame>
|
|
);
|
|
}
|