mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-08 21:56:08 +00:00
9b91f0f42e
Fold the standalone 3x-ui-docs project (Next.js 16 + Fumadocs, deployed to docs.sanaei.dev) into docs/ so the panel and its documentation share a single source of truth, the way sing-box keeps its docs in-tree. The old repo becomes redundant and can be retired. - Import the full site under docs/ (app, components, content, lib, public, scripts, config). The self-contained pnpm project sits alongside the existing engineering notes with no filename collisions. - Re-point "Edit on GitHub" links from MHSanaei/3x-ui-docs to this repo's docs/content/docs path (docs/lib/shared.ts, docs/app/.../page.tsx). - Add docs-ci.yml and docs-deploy.yml under .github/workflows/, scoped to docs/** and run with working-directory: docs, since GitHub only runs workflows from the repo-root .github/. deploy-static.yml's GitHub Pages publish (CNAME docs.sanaei.dev) carries over unchanged. Follow-up (outside this commit): attach the docs.sanaei.dev custom domain to this repository's Pages (or set the Vercel project's root directory to docs), confirm the site is live from the monorepo, then delete MHSanaei/3x-ui-docs.
204 lines
7.3 KiB
TypeScript
204 lines
7.3 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>
|
|
);
|
|
}
|