mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-06 01:56:37 +08:00
feat: add IP selection dropdown for tunnels and forwards
Entire-Checkpoint: fde43d8c94e5
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:端口 或 域名:端口,支持多个地址(每行一个)"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user