feat: add IP selection dropdown for tunnels and forwards

Entire-Checkpoint: fde43d8c94e5
This commit is contained in:
sagitchu
2026-03-03 09:16:05 +08:00
parent b8193417f5
commit 05bd6a686d
4 changed files with 254 additions and 35 deletions
@@ -865,6 +865,9 @@ func (r *Repository) ListTunnels() ([]map[string]interface{}, error) {
if c.Strategy.Valid { if c.Strategy.Valid {
nodeObj["strategy"] = c.Strategy.String nodeObj["strategy"] = c.Strategy.String
} }
if c.ConnectIP.Valid {
nodeObj["connectIp"] = c.ConnectIP.String
}
switch chainTypeInt { switch chainTypeInt {
case 1: case 1:
+2
View File
@@ -217,6 +217,7 @@ export interface NodeMutationPayload {
serverIp?: string; serverIp?: string;
serverIpV4?: string; serverIpV4?: string;
serverIpV6?: string; serverIpV6?: string;
extraIPs?: string;
port?: string; port?: string;
tcpListenAddr?: string; tcpListenAddr?: string;
udpListenAddr?: string; udpListenAddr?: string;
@@ -230,6 +231,7 @@ export interface TunnelChainNodePayload {
nodeId: number; nodeId: number;
protocol?: string; protocol?: string;
strategy?: string; strategy?: string;
connectIp?: string;
chainType?: number; chainType?: number;
inx?: number; inx?: number;
} }
+122 -11
View File
@@ -59,6 +59,8 @@ import {
deleteForward, deleteForward,
forceDeleteForward, forceDeleteForward,
userTunnel, userTunnel,
getTunnelList,
getNodeList,
pauseForwardService, pauseForwardService,
resumeForwardService, resumeForwardService,
diagnoseForward, diagnoseForward,
@@ -117,10 +119,20 @@ interface Forward {
interface Tunnel { interface Tunnel {
id: number; id: number;
name: string; name: string;
inIp?: string;
inNodeId?: Array<{ nodeId: number }>;
inNodePortSta?: number; inNodePortSta?: number;
inNodePortEnd?: number; inNodePortEnd?: number;
} }
interface Node {
id: number;
serverIp?: string;
serverIpV4?: string;
serverIpV6?: string;
extraIPs?: string;
}
interface ForwardForm { interface ForwardForm {
id?: number; id?: number;
userId?: number; userId?: number;
@@ -463,6 +475,8 @@ export default function ForwardPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [forwards, setForwards] = useState<Forward[]>([]); const [forwards, setForwards] = useState<Forward[]>([]);
const [tunnels, setTunnels] = useState<Tunnel[]>([]); const [tunnels, setTunnels] = useState<Tunnel[]>([]);
const [allTunnels, setAllTunnels] = useState<Tunnel[]>([]);
const [nodes, setNodes] = useState<Node[]>([]);
const [speedLimits, setSpeedLimits] = useState<SpeedLimitApiItem[]>([]); const [speedLimits, setSpeedLimits] = useState<SpeedLimitApiItem[]>([]);
const isMobile = useMobileBreakpoint(); const isMobile = useMobileBreakpoint();
const [searchKeyword, setSearchKeyword] = useLocalStorageState( const [searchKeyword, setSearchKeyword] = useLocalStorageState(
@@ -575,6 +589,67 @@ export default function ForwardPage() {
const tokenRoleId = JwtUtil.getRoleIdFromToken(); const tokenRoleId = JwtUtil.getRoleIdFromToken();
const isAdmin = tokenRoleId === 0; const isAdmin = tokenRoleId === 0;
const parseNodeIPs = (node?: Node): string[] => {
if (!node) {
return [];
}
const ips: string[] = [];
const add = (value?: string) => {
const trimmed = (value || "").trim();
if (trimmed) {
ips.push(trimmed);
}
};
add(node.serverIpV4);
add(node.serverIpV6);
add(node.serverIp);
(node.extraIPs || "")
.split(",")
.map((v) => v.trim())
.filter((v) => v)
.forEach((v) => ips.push(v));
return Array.from(new Set(ips));
};
const tunnelInIpOptionMap = useMemo(() => {
const map = new Map<number, string[]>();
const nodeMap = new Map<number, Node>(nodes.map((n) => [n.id, n]));
for (const tunnel of allTunnels) {
const collected: string[] = [];
const entryNodes = tunnel.inNodeId || [];
for (const entry of entryNodes) {
collected.push(...parseNodeIPs(nodeMap.get(entry.nodeId)));
}
if (collected.length === 0) {
(tunnel.inIp || "")
.split(",")
.map((v) => v.trim())
.filter((v) => v)
.forEach((v) => collected.push(v));
}
map.set(tunnel.id, Array.from(new Set(collected)));
}
return map;
}, [allTunnels, nodes]);
const currentTunnelIpOptions = useMemo(() => {
if (!form.tunnelId) {
return [];
}
return tunnelInIpOptionMap.get(form.tunnelId) || [];
}, [form.tunnelId, tunnelInIpOptionMap]);
useEffect(() => { useEffect(() => {
return () => { return () => {
diagnosisAbortRef.current?.abort(); diagnosisAbortRef.current?.abort();
@@ -1035,6 +1110,10 @@ export default function ForwardPage() {
userTunnel(), userTunnel(),
getSpeedLimitList(), getSpeedLimitList(),
]); ]);
const [allTunnelsRes, nodesRes] = await Promise.allSettled([
getTunnelList(),
getNodeList(),
]);
if (forwardsRes.code === 0) { if (forwardsRes.code === 0) {
const forwardsData = const forwardsData =
@@ -1068,6 +1147,14 @@ export default function ForwardPage() {
} else { } else {
} }
if (allTunnelsRes.status === "fulfilled" && allTunnelsRes.value.code === 0) {
setAllTunnels((allTunnelsRes.value.data || []) as Tunnel[]);
}
if (nodesRes.status === "fulfilled" && nodesRes.value.code === 0) {
setNodes((nodesRes.value.data || []) as Node[]);
}
if (speedLimitsRes.code === 0) { if (speedLimitsRes.code === 0) {
setSpeedLimits(speedLimitsRes.data || []); setSpeedLimits(speedLimitsRes.data || []);
} }
@@ -1240,7 +1327,14 @@ export default function ForwardPage() {
// 处理隧道选择变化 // 处理隧道选择变化
const handleTunnelChange = (tunnelId: string) => { const handleTunnelChange = (tunnelId: string) => {
setForm((prev) => ({ ...prev, tunnelId: parseInt(tunnelId) })); const nextTunnelId = parseInt(tunnelId);
const options = tunnelInIpOptionMap.get(nextTunnelId) || [];
setForm((prev) => ({
...prev,
tunnelId: nextTunnelId,
inIp: options.includes(prev.inIp) ? prev.inIp : "",
}));
}; };
// 提交表单 // 提交表单
@@ -1267,7 +1361,7 @@ export default function ForwardPage() {
name: form.name, name: form.name,
tunnelId: form.tunnelId, tunnelId: form.tunnelId,
inPort: form.inPort, inPort: form.inPort,
inIp: form.inIp || null, inIp: form.inIp || undefined,
remoteAddr: processedRemoteAddr, remoteAddr: processedRemoteAddr,
strategy: addressCount > 1 ? form.strategy : "fifo", strategy: addressCount > 1 ? form.strategy : "fifo",
speedId: normalizeSpeedId(form.speedId), speedId: normalizeSpeedId(form.speedId),
@@ -1279,7 +1373,7 @@ export default function ForwardPage() {
name: form.name, name: form.name,
tunnelId: form.tunnelId, tunnelId: form.tunnelId,
inPort: form.inPort, inPort: form.inPort,
inIp: form.inIp || null, inIp: form.inIp || undefined,
remoteAddr: processedRemoteAddr, remoteAddr: processedRemoteAddr,
strategy: addressCount > 1 ? form.strategy : "fifo", strategy: addressCount > 1 ? form.strategy : "fifo",
speedId: normalizeSpeedId(form.speedId), speedId: normalizeSpeedId(form.speedId),
@@ -4028,16 +4122,33 @@ export default function ForwardPage() {
}} }}
/> />
<Input <Select
description="留空使用默认入口IP,多IP节点可指定监听地址" description="从入口节点IP中选择,留空使用默认"
isDisabled={!form.tunnelId || currentTunnelIpOptions.length === 0}
label="监听IP" label="监听IP"
placeholder="留空使用默认" placeholder={
value={form.inIp} form.tunnelId
variant="bordered" ? currentTunnelIpOptions.length > 0
onChange={(e) => ? "选择入口监听IP"
setForm((prev) => ({ ...prev, inIp: e.target.value })) : "当前隧道入口节点暂无可选IP"
: "请先选择隧道"
} }
/> selectedKeys={[form.inIp || "__default__"]}
variant="bordered"
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
setForm((prev) => ({
...prev,
inIp: selectedKey === "__default__" ? "" : selectedKey,
}));
}}
>
<SelectItem key="__default__">默认入口IP</SelectItem>
{currentTunnelIpOptions.map((ip) => (
<SelectItem key={ip}>{ip}</SelectItem>
))}
</Select>
<Textarea <Textarea
description="格式: IP:端口 或 域名:端口,支持多个地址(每行一个)" description="格式: IP:端口 或 域名:端口,支持多个地址(每行一个)"
+127 -24
View File
@@ -95,6 +95,10 @@ interface Node {
id: number; id: number;
name: string; name: string;
status: number; // 1: 在线, 0: 离线 status: number; // 1: 在线, 0: 离线
serverIp?: string;
serverIpV4?: string;
serverIpV6?: string;
extraIPs?: string;
} }
interface TunnelForm { interface TunnelForm {
@@ -144,6 +148,46 @@ export default function TunnelPage() {
}); });
const diagnosisAbortRef = useRef<AbortController | null>(null); const diagnosisAbortRef = useRef<AbortController | null>(null);
const getNodeIpOptions = (nodeId: number): string[] => {
const node = nodes.find((item) => item.id === nodeId);
if (!node) {
return [];
}
const values: string[] = [];
const push = (value?: string) => {
const trimmed = (value || "").trim();
if (trimmed) {
values.push(trimmed);
}
};
push(node.serverIpV4);
push(node.serverIpV6);
push(node.serverIp);
(node.extraIPs || "")
.split(",")
.map((v) => v.trim())
.filter((v) => v)
.forEach((v) => values.push(v));
return Array.from(new Set(values));
};
const getCommonIpOptions = (nodeIds: number[]): string[] => {
if (nodeIds.length === 0) {
return [];
}
const optionSets = nodeIds.map((nodeId) => new Set(getNodeIpOptions(nodeId)));
const base = optionSets[0];
return Array.from(base).filter((ip) => optionSets.every((set) => set.has(ip)));
};
// 表单状态 // 表单状态
const [form, setForm] = useState<TunnelForm>(createTunnelFormDefaults()); const [form, setForm] = useState<TunnelForm>(createTunnelFormDefaults());
@@ -1545,6 +1589,15 @@ export default function TunnelPage() {
groupNodes.length > 0 groupNodes.length > 0
? groupNodes[0].strategy || "round" ? groupNodes[0].strategy || "round"
: "round"; : "round";
const groupSelectedNodeIds = groupNodes
.filter((ct) => ct.nodeId !== -1)
.map((ct) => ct.nodeId);
const groupIpOptions =
getCommonIpOptions(groupSelectedNodeIds);
const selectedGroupConnectIp =
groupNodes.length > 0
? groupNodes[0].connectIp || ""
: "";
return ( return (
<div <div
@@ -1758,27 +1811,45 @@ export default function TunnelPage() {
</div> </div>
{/* 连接IP - 转发链节点 */} {/* 连接IP - 转发链节点 */}
<Input <Select
classNames={{ classNames={{
label: "text-xs", label: "text-xs",
value: "text-sm",
}} }}
description="多IP节点可指定连接地址,留空使用默认" description="按当前跳所选节点的共有IP进行选择,留空使用默认"
label="连接IP" isDisabled={
placeholder="留空使用默认" groupSelectedNodeIds.length === 0 ||
size="sm" groupIpOptions.length === 0
value={
groupNodes.length > 0
? groupNodes[0].connectIp || ""
: ""
} }
label="连接IP"
placeholder={
groupSelectedNodeIds.length === 0
? "请先选择节点"
: groupIpOptions.length > 0
? "选择连接IP"
: "所选节点无共同可选IP"
}
selectedKeys={[
selectedGroupConnectIp || "__default__",
]}
size="sm"
variant="bordered" variant="bordered"
onChange={(e) => { onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
updateChainConnectIp( updateChainConnectIp(
groupIndex, groupIndex,
e.target.value, selectedKey === "__default__"
? ""
: selectedKey,
); );
}} }}
/> >
<SelectItem key="__default__">默认连接IP</SelectItem>
{groupIpOptions.map((ip) => (
<SelectItem key={ip}>{ip}</SelectItem>
))}
</Select>
</div> </div>
); );
})} })}
@@ -2042,22 +2113,45 @@ export default function TunnelPage() {
</div> </div>
{/* 连接IP - 出口节点 */} {/* 连接IP - 出口节点 */}
<Input <Select
classNames={{ classNames={{
label: "text-xs", label: "text-xs",
value: "text-sm",
}} }}
description="多IP节点可指定连接地址,留空使用默认" description="按出口节点共同可用IP选择,留空使用默认"
label="连接IP" isDisabled={
placeholder="留空使用默认" (form.outNodeId || []).filter((ct) => ct.nodeId !== -1)
size="sm" .length === 0 ||
value={ getCommonIpOptions(
form.outNodeId && form.outNodeId.length > 0 (form.outNodeId || [])
? form.outNodeId[0].connectIp || "" .filter((ct) => ct.nodeId !== -1)
: "" .map((ct) => ct.nodeId),
).length === 0
} }
label="连接IP"
placeholder={
(form.outNodeId || []).filter((ct) => ct.nodeId !== -1)
.length === 0
? "请先选择出口节点"
: getCommonIpOptions(
(form.outNodeId || [])
.filter((ct) => ct.nodeId !== -1)
.map((ct) => ct.nodeId),
).length > 0
? "选择连接IP"
: "所选节点无共同可选IP"
}
selectedKeys={[
form.outNodeId && form.outNodeId.length > 0
? form.outNodeId[0].connectIp || "__default__"
: "__default__",
]}
size="sm"
variant="bordered" variant="bordered"
onChange={(e) => { onSelectionChange={(keys) => {
const value = e.target.value; const selectedKey = Array.from(keys)[0] as string;
const value =
selectedKey === "__default__" ? "" : selectedKey;
setForm((prev) => { setForm((prev) => {
const currentOutNodes = prev.outNodeId || []; const currentOutNodes = prev.outNodeId || [];
if (currentOutNodes.length === 0) { if (currentOutNodes.length === 0) {
@@ -2083,7 +2177,16 @@ export default function TunnelPage() {
}; };
}); });
}} }}
/> >
<SelectItem key="__default__">默认连接IP</SelectItem>
{getCommonIpOptions(
(form.outNodeId || [])
.filter((ct) => ct.nodeId !== -1)
.map((ct) => ct.nodeId),
).map((ip) => (
<SelectItem key={ip}>{ip}</SelectItem>
))}
</Select>
</> </>
)} )}
</div> </div>