mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-06 10:06:36 +08:00
1641 lines
76 KiB
TypeScript
1641 lines
76 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { Card, CardBody, CardHeader } from "@heroui/card";
|
|
import { Button } from "@heroui/button";
|
|
import { Input, Textarea } from "@heroui/input";
|
|
import { Select, SelectItem } from "@heroui/select";
|
|
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/modal";
|
|
import { Chip } from "@heroui/chip";
|
|
import { Spinner } from "@heroui/spinner";
|
|
import { Divider } from "@heroui/divider";
|
|
import { Alert } from "@heroui/alert";
|
|
import toast from 'react-hot-toast';
|
|
|
|
|
|
import {
|
|
createTunnel,
|
|
getTunnelList,
|
|
updateTunnel,
|
|
deleteTunnel,
|
|
getNodeList,
|
|
diagnoseTunnel
|
|
} from "@/api";
|
|
|
|
interface ChainTunnel {
|
|
nodeId: number;
|
|
protocol?: string; // 'tls' | 'wss' | 'tcp' | 'mtls' | 'mwss' | 'mtcp' - 转发链协议
|
|
strategy?: string; // 'fifo' | 'round' | 'rand' - 仅转发链需要
|
|
chainType?: number; // 1: 入口, 2: 转发链, 3: 出口
|
|
inx?: number; // 转发链序号
|
|
}
|
|
|
|
interface Tunnel {
|
|
id: number;
|
|
name: string;
|
|
type: number; // 1: 端口转发, 2: 隧道转发
|
|
inNodeId: ChainTunnel[]; // 入口节点列表
|
|
outNodeId?: ChainTunnel[]; // 出口节点列表
|
|
chainNodes?: ChainTunnel[][]; // 转发链节点列表,二维数组
|
|
inIp: string;
|
|
outIp?: string;
|
|
protocol?: string;
|
|
flow: number; // 1: 单向, 2: 双向
|
|
trafficRatio: number;
|
|
status: number;
|
|
createdTime: string;
|
|
}
|
|
|
|
interface Node {
|
|
id: number;
|
|
name: string;
|
|
status: number; // 1: 在线, 0: 离线
|
|
}
|
|
|
|
interface TunnelForm {
|
|
id?: number;
|
|
name: string;
|
|
type: number;
|
|
inNodeId: ChainTunnel[];
|
|
outNodeId?: ChainTunnel[];
|
|
chainNodes?: ChainTunnel[][]; // 转发链节点列表,二维数组,外层是跳数,内层是该跳的节点
|
|
flow: number;
|
|
trafficRatio: number;
|
|
inIp: string; // 入口IP
|
|
status: number;
|
|
}
|
|
|
|
interface DiagnosisResult {
|
|
tunnelName: string;
|
|
tunnelType: string;
|
|
timestamp: number;
|
|
results: Array<{
|
|
success: boolean;
|
|
description: string;
|
|
nodeName: string;
|
|
nodeId: string;
|
|
targetIp: string;
|
|
targetPort?: number;
|
|
message?: string;
|
|
averageTime?: number;
|
|
packetLoss?: number;
|
|
fromChainType?: number; // 1: 入口, 2: 链, 3: 出口
|
|
fromInx?: number;
|
|
toChainType?: number;
|
|
toInx?: number;
|
|
}>;
|
|
}
|
|
|
|
export default function TunnelPage() {
|
|
const [loading, setLoading] = useState(true);
|
|
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
|
const [nodes, setNodes] = useState<Node[]>([]);
|
|
|
|
// 模态框状态
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
|
const [diagnosisModalOpen, setDiagnosisModalOpen] = useState(false);
|
|
const [isEdit, setIsEdit] = useState(false);
|
|
const [submitLoading, setSubmitLoading] = useState(false);
|
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
|
const [diagnosisLoading, setDiagnosisLoading] = useState(false);
|
|
const [tunnelToDelete, setTunnelToDelete] = useState<Tunnel | null>(null);
|
|
const [currentDiagnosisTunnel, setCurrentDiagnosisTunnel] = useState<Tunnel | null>(null);
|
|
const [diagnosisResult, setDiagnosisResult] = useState<DiagnosisResult | null>(null);
|
|
|
|
// 表单状态
|
|
const [form, setForm] = useState<TunnelForm>({
|
|
name: '',
|
|
type: 1,
|
|
inNodeId: [],
|
|
outNodeId: [],
|
|
chainNodes: [],
|
|
flow: 1,
|
|
trafficRatio: 1.0,
|
|
inIp: '',
|
|
status: 1
|
|
});
|
|
|
|
// 表单验证错误
|
|
const [errors, setErrors] = useState<{[key: string]: string}>({});
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, []);
|
|
|
|
// 加载所有数据
|
|
const loadData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [tunnelsRes, nodesRes] = await Promise.all([
|
|
getTunnelList(),
|
|
getNodeList()
|
|
]);
|
|
|
|
if (tunnelsRes.code === 0) {
|
|
setTunnels(tunnelsRes.data || []);
|
|
} else {
|
|
toast.error(tunnelsRes.msg || '获取隧道列表失败');
|
|
}
|
|
|
|
if (nodesRes.code === 0) {
|
|
setNodes(nodesRes.data || []);
|
|
} else {
|
|
console.warn('获取节点列表失败:', nodesRes.msg);
|
|
}
|
|
} catch (error) {
|
|
console.error('加载数据失败:', error);
|
|
toast.error('加载数据失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// 表单验证
|
|
const validateForm = (): boolean => {
|
|
const newErrors: {[key: string]: string} = {};
|
|
|
|
if (!form.name.trim()) {
|
|
newErrors.name = '请输入隧道名称';
|
|
} else if (form.name.length < 2 || form.name.length > 50) {
|
|
newErrors.name = '隧道名称长度应在2-50个字符之间';
|
|
}
|
|
|
|
if (!form.inNodeId || form.inNodeId.length === 0) {
|
|
newErrors.inNodeId = '请至少选择一个入口节点';
|
|
} else {
|
|
// 验证所有选择的节点都在线
|
|
const offlineNodes = form.inNodeId.filter(item => {
|
|
const node = nodes.find(n => n.id === item.nodeId);
|
|
return node && node.status !== 1;
|
|
});
|
|
if (offlineNodes.length > 0) {
|
|
newErrors.inNodeId = '所有入口节点必须在线';
|
|
}
|
|
}
|
|
|
|
if (form.trafficRatio < 0.0 || form.trafficRatio > 100.0) {
|
|
newErrors.trafficRatio = '流量倍率必须在0.0-100.0之间';
|
|
}
|
|
|
|
// 隧道转发时的验证
|
|
if (form.type === 2) {
|
|
if (!form.outNodeId || form.outNodeId.length === 0) {
|
|
newErrors.outNodeId = '请至少选择一个出口节点';
|
|
} else {
|
|
// 验证所有选择的节点都在线
|
|
const offlineNodes = form.outNodeId.filter(item => {
|
|
const node = nodes.find(n => n.id === item.nodeId);
|
|
return node && node.status !== 1;
|
|
});
|
|
if (offlineNodes.length > 0) {
|
|
newErrors.outNodeId = '所有出口节点必须在线';
|
|
}
|
|
|
|
// 检查是否有重复节点
|
|
const inNodeIds = form.inNodeId.map(item => item.nodeId);
|
|
const outNodeIds = form.outNodeId.map(item => item.nodeId);
|
|
const overlap = inNodeIds.filter(id => outNodeIds.includes(id));
|
|
if (overlap.length > 0) {
|
|
newErrors.outNodeId = '隧道转发模式下,入口和出口不能有相同节点';
|
|
}
|
|
}
|
|
}
|
|
|
|
setErrors(newErrors);
|
|
return Object.keys(newErrors).length === 0;
|
|
};
|
|
|
|
// 新增隧道
|
|
const handleAdd = () => {
|
|
setIsEdit(false);
|
|
setForm({
|
|
name: '',
|
|
type: 1,
|
|
inNodeId: [],
|
|
outNodeId: [],
|
|
chainNodes: [],
|
|
flow: 1,
|
|
trafficRatio: 1.0,
|
|
inIp: '',
|
|
status: 1
|
|
});
|
|
setErrors({});
|
|
setModalOpen(true);
|
|
};
|
|
|
|
// 编辑隧道 - 只能修改部分字段
|
|
const handleEdit = (tunnel: Tunnel) => {
|
|
setIsEdit(true);
|
|
|
|
// 直接使用列表数据,getAllTunnels 已经包含完整的节点信息
|
|
setForm({
|
|
id: tunnel.id,
|
|
name: tunnel.name,
|
|
type: tunnel.type,
|
|
inNodeId: tunnel.inNodeId || [],
|
|
outNodeId: tunnel.outNodeId || [],
|
|
chainNodes: tunnel.chainNodes || [],
|
|
flow: tunnel.flow,
|
|
trafficRatio: tunnel.trafficRatio,
|
|
inIp: tunnel.inIp ? tunnel.inIp.split(',').map(ip => ip.trim()).join('\n') : '',
|
|
status: tunnel.status
|
|
});
|
|
setErrors({});
|
|
setModalOpen(true);
|
|
};
|
|
|
|
// 删除隧道
|
|
const handleDelete = (tunnel: Tunnel) => {
|
|
setTunnelToDelete(tunnel);
|
|
setDeleteModalOpen(true);
|
|
};
|
|
|
|
const confirmDelete = async () => {
|
|
if (!tunnelToDelete) return;
|
|
|
|
setDeleteLoading(true);
|
|
try {
|
|
const response = await deleteTunnel(tunnelToDelete.id);
|
|
if (response.code === 0) {
|
|
toast.success('删除成功');
|
|
setDeleteModalOpen(false);
|
|
setTunnelToDelete(null);
|
|
loadData();
|
|
} else {
|
|
toast.error(response.msg || '删除失败');
|
|
}
|
|
} catch (error) {
|
|
console.error('删除失败:', error);
|
|
toast.error('删除失败');
|
|
} finally {
|
|
setDeleteLoading(false);
|
|
}
|
|
};
|
|
|
|
// 隧道类型改变时的处理
|
|
const handleTypeChange = (type: number) => {
|
|
setForm(prev => ({
|
|
...prev,
|
|
type,
|
|
outNodeId: type === 1 ? [] : prev.outNodeId,
|
|
chainNodes: type === 1 ? [] : prev.chainNodes
|
|
}));
|
|
};
|
|
|
|
// 删除转发链中的某一跳(删除整个分组)
|
|
const removeChainNode = (groupIndex: number) => {
|
|
setForm(prev => ({
|
|
...prev,
|
|
chainNodes: (prev.chainNodes || []).filter((_, index) => index !== groupIndex)
|
|
}));
|
|
};
|
|
|
|
// 添加节点到指定的转发链跳数
|
|
const addNodeToChain = (groupIndex: number, nodeId: number) => {
|
|
setForm(prev => {
|
|
const chainNodes = [...(prev.chainNodes || [])];
|
|
const group = chainNodes[groupIndex] || [];
|
|
|
|
// 获取当前组的策略和协议
|
|
const strategy = group.length > 0 ? group[0].strategy : 'round';
|
|
const protocol = group.length > 0 ? group[0].protocol : 'tls';
|
|
|
|
// 添加节点到该组
|
|
chainNodes[groupIndex] = [
|
|
...group,
|
|
{ nodeId, chainType: 2, protocol, strategy }
|
|
];
|
|
|
|
return { ...prev, chainNodes };
|
|
});
|
|
};
|
|
|
|
// 从某一跳删除指定节点
|
|
const removeNodeFromChain = (groupIndex: number, nodeId: number) => {
|
|
setForm(prev => {
|
|
const chainNodes = [...(prev.chainNodes || [])];
|
|
chainNodes[groupIndex] = (chainNodes[groupIndex] || []).filter(node => node.nodeId !== nodeId);
|
|
return { ...prev, chainNodes };
|
|
});
|
|
};
|
|
|
|
// 更新某一跳的所有节点的协议
|
|
const updateChainProtocol = (groupIndex: number, protocol: string) => {
|
|
setForm(prev => {
|
|
const chainNodes = [...(prev.chainNodes || [])];
|
|
chainNodes[groupIndex] = (chainNodes[groupIndex] || []).map(node => ({ ...node, protocol }));
|
|
return { ...prev, chainNodes };
|
|
});
|
|
};
|
|
|
|
// 更新某一跳的所有节点的策略
|
|
const updateChainStrategy = (groupIndex: number, strategy: string) => {
|
|
setForm(prev => {
|
|
const chainNodes = [...(prev.chainNodes || [])];
|
|
chainNodes[groupIndex] = (chainNodes[groupIndex] || []).map(node => ({ ...node, strategy }));
|
|
return { ...prev, chainNodes };
|
|
});
|
|
};
|
|
|
|
// 获取所有转发链中已选择的节点ID列表
|
|
const getSelectedChainNodeIds = (): number[] => {
|
|
return (form.chainNodes || []).flatMap(group => group.map(node => node.nodeId));
|
|
};
|
|
|
|
// 获取转发链分组(已经是二维数组)
|
|
const getChainGroups = (): ChainTunnel[][] => {
|
|
return form.chainNodes || [];
|
|
};
|
|
|
|
// 提交表单
|
|
const handleSubmit = async () => {
|
|
if (!validateForm()) return;
|
|
|
|
setSubmitLoading(true);
|
|
try {
|
|
// 过滤掉占位节点(nodeId === -1 的节点)
|
|
const cleanedChainNodes = (form.chainNodes || [])
|
|
.map(group => group.filter(node => node.nodeId !== -1))
|
|
.filter(group => group.length > 0); // 移除空组
|
|
|
|
// 过滤掉出口节点中的占位节点
|
|
const cleanedOutNodeId = (form.outNodeId || []).filter(node => node.nodeId !== -1);
|
|
|
|
// 将换行符分隔的IP转换为逗号分隔
|
|
const inIpString = form.inIp
|
|
.split('\n')
|
|
.map(ip => ip.trim())
|
|
.filter(ip => ip)
|
|
.join(',');
|
|
|
|
const data = {
|
|
...form,
|
|
inIp: inIpString,
|
|
outNodeId: cleanedOutNodeId,
|
|
chainNodes: cleanedChainNodes
|
|
};
|
|
|
|
const response = isEdit
|
|
? await updateTunnel(data)
|
|
: await createTunnel(data);
|
|
|
|
if (response.code === 0) {
|
|
toast.success(isEdit ? '更新成功' : '创建成功');
|
|
setModalOpen(false);
|
|
loadData();
|
|
} else {
|
|
toast.error(response.msg || (isEdit ? '更新失败' : '创建失败'));
|
|
}
|
|
} catch (error) {
|
|
console.error('提交失败:', error);
|
|
toast.error('网络错误,请重试');
|
|
} finally {
|
|
setSubmitLoading(false);
|
|
}
|
|
};
|
|
|
|
// 诊断隧道
|
|
const handleDiagnose = async (tunnel: Tunnel) => {
|
|
setCurrentDiagnosisTunnel(tunnel);
|
|
setDiagnosisModalOpen(true);
|
|
setDiagnosisLoading(true);
|
|
setDiagnosisResult(null);
|
|
|
|
try {
|
|
const response = await diagnoseTunnel(tunnel.id);
|
|
if (response.code === 0) {
|
|
setDiagnosisResult(response.data);
|
|
} else {
|
|
toast.error(response.msg || '诊断失败');
|
|
setDiagnosisResult({
|
|
tunnelName: tunnel.name,
|
|
tunnelType: tunnel.type === 1 ? '端口转发' : '隧道转发',
|
|
timestamp: Date.now(),
|
|
results: [{
|
|
success: false,
|
|
description: '诊断失败',
|
|
nodeName: '-',
|
|
nodeId: '-',
|
|
targetIp: '-',
|
|
targetPort: 443,
|
|
message: response.msg || '诊断过程中发生错误'
|
|
}]
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('诊断失败:', error);
|
|
toast.error('网络错误,请重试');
|
|
setDiagnosisResult({
|
|
tunnelName: tunnel.name,
|
|
tunnelType: tunnel.type === 1 ? '端口转发' : '隧道转发',
|
|
timestamp: Date.now(),
|
|
results: [{
|
|
success: false,
|
|
description: '网络错误',
|
|
nodeName: '-',
|
|
nodeId: '-',
|
|
targetIp: '-',
|
|
targetPort: 443,
|
|
message: '无法连接到服务器'
|
|
}]
|
|
});
|
|
} finally {
|
|
setDiagnosisLoading(false);
|
|
}
|
|
};
|
|
|
|
|
|
// 获取类型显示
|
|
const getTypeDisplay = (type: number) => {
|
|
switch (type) {
|
|
case 1:
|
|
return { text: '端口转发', color: 'primary' };
|
|
case 2:
|
|
return { text: '隧道转发', color: 'secondary' };
|
|
default:
|
|
return { text: '未知', color: 'default' };
|
|
}
|
|
};
|
|
|
|
// 获取流量计算显示
|
|
const getFlowDisplay = (flow: number) => {
|
|
switch (flow) {
|
|
case 1:
|
|
return '单向计算';
|
|
case 2:
|
|
return '双向计算';
|
|
default:
|
|
return '未知';
|
|
}
|
|
};
|
|
|
|
|
|
// 获取连接质量
|
|
const getQualityDisplay = (averageTime?: number, packetLoss?: number) => {
|
|
if (averageTime === undefined || packetLoss === undefined) return null;
|
|
|
|
if (averageTime < 30 && packetLoss === 0) return { text: '🚀 优秀', color: 'success' };
|
|
if (averageTime < 50 && packetLoss === 0) return { text: '✨ 很好', color: 'success' };
|
|
if (averageTime < 100 && packetLoss < 1) return { text: '👍 良好', color: 'primary' };
|
|
if (averageTime < 150 && packetLoss < 2) return { text: '😐 一般', color: 'warning' };
|
|
if (averageTime < 200 && packetLoss < 5) return { text: '😟 较差', color: 'warning' };
|
|
return { text: '😵 很差', color: 'danger' };
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="flex items-center gap-3">
|
|
<Spinner size="sm" />
|
|
<span className="text-default-600">正在加载...</span>
|
|
</div>
|
|
</div>
|
|
|
|
);
|
|
}
|
|
|
|
return (
|
|
|
|
<div className="px-3 lg:px-6 py-8">
|
|
{/* 页面头部 */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex-1">
|
|
</div>
|
|
|
|
<Button
|
|
size="sm"
|
|
variant="flat"
|
|
color="primary"
|
|
onPress={handleAdd}
|
|
|
|
>
|
|
新增
|
|
</Button>
|
|
|
|
</div>
|
|
|
|
{/* 隧道卡片网格 */}
|
|
{tunnels.length > 0 ? (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
|
{tunnels.map((tunnel) => {
|
|
const typeDisplay = getTypeDisplay(tunnel.type);
|
|
|
|
return (
|
|
<Card key={tunnel.id} className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200">
|
|
<CardHeader className="pb-2">
|
|
<div className="flex justify-between items-start w-full">
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="font-semibold text-foreground truncate text-sm">{tunnel.name}</h3>
|
|
<div className="flex items-center gap-1.5 mt-1">
|
|
<Chip
|
|
color={typeDisplay.color as any}
|
|
variant="flat"
|
|
size="sm"
|
|
className="text-xs"
|
|
>
|
|
{typeDisplay.text}
|
|
</Chip>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardBody className="pt-0 pb-3">
|
|
<div className="space-y-3">
|
|
{/* 拓扑结构 */}
|
|
<div className="pt-2 border-t border-divider">
|
|
<div className="flex items-center justify-center gap-2 text-xs">
|
|
{/* 入口节点 */}
|
|
<div className="flex items-center gap-1 px-2 py-1 bg-primary-50 dark:bg-primary-100/20 rounded border border-primary-200 dark:border-primary-300/20">
|
|
<svg className="w-3 h-3 text-primary-600" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fillRule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 011 1v12a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm2 2v8h10V6H5z" clipRule="evenodd" />
|
|
</svg>
|
|
<span className="font-semibold text-primary-700 dark:text-primary-400">
|
|
{tunnel.inNodeId?.length || 0}入口
|
|
</span>
|
|
</div>
|
|
|
|
{/* 箭头 */}
|
|
<svg className="w-4 h-4 text-default-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
|
|
{/* 转发链 */}
|
|
<div className="flex items-center gap-1 px-2 py-1 bg-secondary-50 dark:bg-secondary-100/20 rounded border border-secondary-200 dark:border-secondary-300/20">
|
|
<svg className="w-3 h-3 text-secondary-600" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fillRule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clipRule="evenodd" />
|
|
</svg>
|
|
<span className="font-semibold text-secondary-700 dark:text-secondary-400">
|
|
{tunnel.type === 2 ? (tunnel.chainNodes?.length || 0) : 0}跳
|
|
</span>
|
|
</div>
|
|
|
|
{/* 箭头 */}
|
|
<svg className="w-4 h-4 text-default-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
|
|
{/* 出口节点 */}
|
|
<div className="flex items-center gap-1 px-2 py-1 bg-success-50 dark:bg-success-100/20 rounded border border-success-200 dark:border-success-300/20">
|
|
<svg className="w-3 h-3 text-success-600" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-8.707l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L9 9.414V13a1 1 0 102 0V9.414l1.293 1.293a1 1 0 001.414-1.414z" clipRule="evenodd" />
|
|
</svg>
|
|
<span className="font-semibold text-success-700 dark:text-success-400">
|
|
{tunnel.type === 2 ? (tunnel.outNodeId?.length || 0) : (tunnel.inNodeId?.length || 0)}出口
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
{/* 流量配置 */}
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100/30 rounded">
|
|
<div className="text-xs text-default-500">流量计算</div>
|
|
<div className="text-sm font-semibold text-foreground mt-0.5">
|
|
{getFlowDisplay(tunnel.flow)}
|
|
</div>
|
|
</div>
|
|
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100/30 rounded">
|
|
<div className="text-xs text-default-500">流量倍率</div>
|
|
<div className="text-sm font-semibold text-foreground mt-0.5">
|
|
{tunnel.trafficRatio}x
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-1.5 mt-3">
|
|
<Button
|
|
size="sm"
|
|
variant="flat"
|
|
color="primary"
|
|
onPress={() => handleEdit(tunnel)}
|
|
className="flex-1 min-h-8"
|
|
startContent={
|
|
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
|
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
|
</svg>
|
|
}
|
|
>
|
|
编辑
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="flat"
|
|
color="warning"
|
|
onPress={() => handleDiagnose(tunnel)}
|
|
className="flex-1 min-h-8"
|
|
startContent={
|
|
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
|
</svg>
|
|
}
|
|
>
|
|
诊断
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="flat"
|
|
color="danger"
|
|
onPress={() => handleDelete(tunnel)}
|
|
className="flex-1 min-h-8"
|
|
startContent={
|
|
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fillRule="evenodd" d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" clipRule="evenodd" />
|
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z" clipRule="evenodd" />
|
|
</svg>
|
|
}
|
|
>
|
|
删除
|
|
</Button>
|
|
</div>
|
|
</CardBody>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
/* 空状态 */
|
|
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
|
|
<CardBody className="text-center py-16">
|
|
<div className="flex flex-col items-center gap-4">
|
|
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
|
|
<svg className="w-8 h-8 text-default-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-foreground">暂无隧道配置</h3>
|
|
<p className="text-default-500 text-sm mt-1">还没有创建任何隧道配置,点击上方按钮开始创建</p>
|
|
</div>
|
|
</div>
|
|
</CardBody>
|
|
</Card>
|
|
)}
|
|
|
|
{/* 新增/编辑模态框 */}
|
|
<Modal
|
|
isOpen={modalOpen}
|
|
onOpenChange={setModalOpen}
|
|
size="2xl"
|
|
scrollBehavior="outside"
|
|
backdrop="blur"
|
|
placement="center"
|
|
>
|
|
<ModalContent>
|
|
{(onClose) => (
|
|
<>
|
|
<ModalHeader className="flex flex-col gap-1">
|
|
<h2 className="text-xl font-bold">
|
|
{isEdit ? '编辑隧道' : '新增隧道'}
|
|
</h2>
|
|
<p className="text-small text-default-500">
|
|
{isEdit ? '编辑时只能修改隧道名称、流量计算和流量倍率' : '创建新的隧道配置'}
|
|
</p>
|
|
</ModalHeader>
|
|
<ModalBody>
|
|
<div className="space-y-4">
|
|
<Input
|
|
label="隧道名称"
|
|
placeholder="请输入隧道名称"
|
|
value={form.name}
|
|
onChange={(e) => setForm(prev => ({ ...prev, name: e.target.value }))}
|
|
isInvalid={!!errors.name}
|
|
errorMessage={errors.name}
|
|
variant="bordered"
|
|
/>
|
|
|
|
<Select
|
|
label="隧道类型"
|
|
placeholder="请选择隧道类型"
|
|
selectedKeys={[form.type.toString()]}
|
|
onSelectionChange={(keys) => {
|
|
const selectedKey = Array.from(keys)[0] as string;
|
|
if (selectedKey) {
|
|
handleTypeChange(parseInt(selectedKey));
|
|
}
|
|
}}
|
|
isInvalid={!!errors.type}
|
|
errorMessage={errors.type}
|
|
variant="bordered"
|
|
isDisabled={isEdit}
|
|
description={isEdit ? "编辑时无法修改隧道类型" : undefined}
|
|
>
|
|
<SelectItem key="1">端口转发</SelectItem>
|
|
<SelectItem key="2">隧道转发</SelectItem>
|
|
</Select>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<Select
|
|
label="流量计算"
|
|
placeholder="请选择流量计算方式"
|
|
selectedKeys={[form.flow.toString()]}
|
|
onSelectionChange={(keys) => {
|
|
const selectedKey = Array.from(keys)[0] as string;
|
|
if (selectedKey) {
|
|
setForm(prev => ({ ...prev, flow: parseInt(selectedKey) }));
|
|
}
|
|
}}
|
|
isInvalid={!!errors.flow}
|
|
errorMessage={errors.flow}
|
|
variant="bordered"
|
|
>
|
|
<SelectItem key="1">单向计算(仅上传)</SelectItem>
|
|
<SelectItem key="2">双向计算(上传+下载)</SelectItem>
|
|
</Select>
|
|
|
|
<Input
|
|
label="流量倍率"
|
|
placeholder="请输入流量倍率"
|
|
type="number"
|
|
value={form.trafficRatio.toString()}
|
|
onChange={(e) => setForm(prev => ({
|
|
...prev,
|
|
trafficRatio: parseFloat(e.target.value) || 0
|
|
}))}
|
|
isInvalid={!!errors.trafficRatio}
|
|
errorMessage={errors.trafficRatio}
|
|
variant="bordered"
|
|
endContent={
|
|
<div className="pointer-events-none flex items-center">
|
|
<span className="text-default-400 text-small">x</span>
|
|
</div>
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<Textarea
|
|
label="入口IP"
|
|
placeholder="一行一个IP地址或域名,例如: 192.168.1.100 example.com"
|
|
value={form.inIp}
|
|
onChange={(e) => setForm(prev => ({ ...prev, inIp: e.target.value }))}
|
|
isInvalid={!!errors.inIp}
|
|
errorMessage={errors.inIp}
|
|
variant="bordered"
|
|
minRows={3}
|
|
maxRows={5}
|
|
description="支持多个IP,每行一个地址,为空时使用入口节点ip"
|
|
/>
|
|
|
|
<Divider />
|
|
<h3 className="text-lg font-semibold">入口配置</h3>
|
|
|
|
<div className="space-y-2">
|
|
<Select
|
|
label="入口节点"
|
|
placeholder="请选择入口节点(可多选)"
|
|
selectionMode="multiple"
|
|
selectedKeys={form.inNodeId.map(ct => ct.nodeId.toString())}
|
|
disabledKeys={[
|
|
...nodes.filter(node => node.status !== 1).map(node => node.id.toString()),
|
|
...(form.outNodeId || []).map(ct => ct.nodeId.toString()),
|
|
...getSelectedChainNodeIds().map(id => id.toString())
|
|
]}
|
|
onSelectionChange={(keys) => {
|
|
const selectedIds = Array.from(keys).map(key => parseInt(key as string));
|
|
const newInNodeId: ChainTunnel[] = selectedIds.map(nodeId => {
|
|
// 保留已有的端口配置
|
|
const existing = form.inNodeId.find(ct => ct.nodeId === nodeId);
|
|
return existing || { nodeId, chainType: 1 };
|
|
});
|
|
setForm(prev => ({ ...prev, inNodeId: newInNodeId }));
|
|
}}
|
|
isInvalid={!!errors.inNodeId}
|
|
errorMessage={errors.inNodeId}
|
|
variant="bordered"
|
|
isDisabled={isEdit}
|
|
>
|
|
{nodes.map((node) => (
|
|
<SelectItem
|
|
key={node.id}
|
|
textValue={`${node.name}`}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span>{node.name}</span>
|
|
<div className="flex items-center gap-2">
|
|
<Chip
|
|
color={node.status === 1 ? 'success' : 'default'}
|
|
variant="flat"
|
|
size="sm"
|
|
>
|
|
{node.status === 1 ? '在线' : '离线'}
|
|
</Chip>
|
|
{form.outNodeId && form.outNodeId.some(ct => ct.nodeId === node.id) && (
|
|
<Chip color="danger" variant="flat" size="sm">
|
|
已选为出口
|
|
</Chip>
|
|
)}
|
|
{getSelectedChainNodeIds().includes(node.id) && (
|
|
<Chip color="primary" variant="flat" size="sm">
|
|
已选为转发链
|
|
</Chip>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 隧道转发时显示转发链配置 */}
|
|
{form.type === 2 && (
|
|
<>
|
|
<Divider />
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-lg font-semibold">转发链配置</h3>
|
|
<Button
|
|
size="sm"
|
|
color="primary"
|
|
variant="flat"
|
|
onPress={() => {
|
|
// 添加新的一跳(一个空组,或包含占位节点)
|
|
setForm(prev => ({
|
|
...prev,
|
|
chainNodes: [
|
|
...(prev.chainNodes || []),
|
|
[{ nodeId: -1, chainType: 2, protocol: 'tls', strategy: 'round' }]
|
|
]
|
|
}));
|
|
}}
|
|
isDisabled={isEdit}
|
|
startContent={
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
}
|
|
>
|
|
添加一跳
|
|
</Button>
|
|
</div>
|
|
|
|
|
|
|
|
{getChainGroups().length > 0 && (
|
|
<div className="space-y-3">
|
|
{getChainGroups().map((groupNodes, groupIndex) => {
|
|
const protocol = groupNodes.length > 0 ? groupNodes[0].protocol || 'tls' : 'tls';
|
|
const strategy = groupNodes.length > 0 ? groupNodes[0].strategy || 'round' : 'round';
|
|
|
|
return (
|
|
<div key={groupIndex} className="border border-default-200 rounded-lg p-3">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-sm font-medium text-default-600">第{groupIndex + 1}跳</span>
|
|
<Button
|
|
size="sm"
|
|
color="danger"
|
|
variant="light"
|
|
isIconOnly
|
|
onPress={() => removeChainNode(groupIndex)}
|
|
isDisabled={isEdit}
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-2">
|
|
{/* 节点选择 - 移动端100%,桌面端50% */}
|
|
<div className="col-span-1 md:col-span-2">
|
|
<Select
|
|
label="节点"
|
|
placeholder="选择节点(可多选)"
|
|
selectionMode="multiple"
|
|
selectedKeys={groupNodes.filter(ct => ct.nodeId !== -1).map(ct => ct.nodeId.toString())}
|
|
disabledKeys={[
|
|
...nodes.filter(node => node.status !== 1).map(node => node.id.toString()),
|
|
...form.inNodeId.map(ct => ct.nodeId.toString()),
|
|
...(form.outNodeId || []).map(ct => ct.nodeId.toString()),
|
|
// 排除其他跳数已选的节点
|
|
...(form.chainNodes || [])
|
|
.flatMap((group, idx) => idx !== groupIndex ? group.map(ct => ct.nodeId) : [])
|
|
.filter(id => id !== -1)
|
|
.map(id => id.toString())
|
|
]}
|
|
onSelectionChange={(keys) => {
|
|
const selectedIds = Array.from(keys).map(key => parseInt(key as string));
|
|
const currentNodes = groupNodes.filter(ct => ct.nodeId !== -1);
|
|
|
|
// 找出新增的节点
|
|
const currentNodeIds = currentNodes.map(ct => ct.nodeId);
|
|
const addedIds = selectedIds.filter(id => !currentNodeIds.includes(id));
|
|
const removedIds = currentNodeIds.filter(id => !selectedIds.includes(id));
|
|
|
|
// 添加新节点
|
|
addedIds.forEach(nodeId => addNodeToChain(groupIndex, nodeId));
|
|
|
|
// 删除取消选择的节点
|
|
removedIds.forEach(nodeId => removeNodeFromChain(groupIndex, nodeId));
|
|
}}
|
|
variant="bordered"
|
|
size="sm"
|
|
isDisabled={isEdit}
|
|
classNames={{
|
|
label: "text-xs",
|
|
value: "text-sm"
|
|
}}
|
|
>
|
|
{nodes.map((node) => (
|
|
<SelectItem
|
|
key={node.id}
|
|
textValue={`${node.name}`}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm">{node.name}</span>
|
|
<div className="flex items-center gap-2">
|
|
<Chip
|
|
color={node.status === 1 ? 'success' : 'default'}
|
|
variant="flat"
|
|
size="sm"
|
|
>
|
|
{node.status === 1 ? '在线' : '离线'}
|
|
</Chip>
|
|
{form.inNodeId.some(ct => ct.nodeId === node.id) && (
|
|
<Chip color="warning" variant="flat" size="sm">
|
|
已选为入口
|
|
</Chip>
|
|
)}
|
|
{form.outNodeId && form.outNodeId.some(ct => ct.nodeId === node.id) && (
|
|
<Chip color="danger" variant="flat" size="sm">
|
|
已选为出口
|
|
</Chip>
|
|
)}
|
|
{/* 显示是否在其他跳数中被选择 */}
|
|
{(form.chainNodes || []).some((group, idx) =>
|
|
idx !== groupIndex && group.some(ct => ct.nodeId === node.id && ct.nodeId !== -1)
|
|
) && (
|
|
<Chip color="primary" variant="flat" size="sm">
|
|
已选为其他跳
|
|
</Chip>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 协议选择 - 25% */}
|
|
<Select
|
|
label="协议"
|
|
placeholder="选择协议"
|
|
selectedKeys={[protocol]}
|
|
onSelectionChange={(keys) => {
|
|
const selectedKey = Array.from(keys)[0] as string;
|
|
if (selectedKey) {
|
|
updateChainProtocol(groupIndex, selectedKey);
|
|
}
|
|
}}
|
|
variant="bordered"
|
|
size="sm"
|
|
isDisabled={isEdit}
|
|
classNames={{
|
|
label: "text-xs",
|
|
value: "text-sm"
|
|
}}
|
|
>
|
|
<SelectItem key="tls">TLS</SelectItem>
|
|
<SelectItem key="wss">WSS</SelectItem>
|
|
<SelectItem key="tcp">TCP</SelectItem>
|
|
<SelectItem key="mtls">MTLS</SelectItem>
|
|
<SelectItem key="mwss">MWSS</SelectItem>
|
|
<SelectItem key="mtcp">MTCP</SelectItem>
|
|
</Select>
|
|
|
|
{/* 负载策略 - 25% */}
|
|
<Select
|
|
label="负载策略"
|
|
placeholder="选择策略"
|
|
selectedKeys={[strategy]}
|
|
onSelectionChange={(keys) => {
|
|
const selectedKey = Array.from(keys)[0] as string;
|
|
if (selectedKey) {
|
|
updateChainStrategy(groupIndex, selectedKey);
|
|
}
|
|
}}
|
|
variant="bordered"
|
|
size="sm"
|
|
isDisabled={isEdit}
|
|
classNames={{
|
|
label: "text-xs",
|
|
value: "text-sm"
|
|
}}
|
|
>
|
|
<SelectItem key="fifo">主备</SelectItem>
|
|
<SelectItem key="round">轮询</SelectItem>
|
|
<SelectItem key="rand">随机</SelectItem>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{getChainGroups().length === 0 && (
|
|
<div className="text-center py-8 bg-default-50 dark:bg-default-100/50 rounded border border-dashed border-default-300">
|
|
<p className="text-sm text-default-500">还没有添加转发链,点击上方"添加一跳"按钮开始添加</p>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* 隧道转发时显示出口配置 */}
|
|
{form.type === 2 && (
|
|
<>
|
|
<Divider />
|
|
<h3 className="text-lg font-semibold">出口配置</h3>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-2">
|
|
{/* 节点选择 - 移动端100%,桌面端50% */}
|
|
<div className="col-span-1 md:col-span-2">
|
|
<Select
|
|
label="节点"
|
|
placeholder="请选择出口节点(可多选)"
|
|
selectionMode="multiple"
|
|
selectedKeys={form.outNodeId ? form.outNodeId.filter(ct => ct.nodeId !== -1).map(ct => ct.nodeId.toString()) : []}
|
|
disabledKeys={[
|
|
...nodes.filter(node => node.status !== 1).map(node => node.id.toString()),
|
|
...form.inNodeId.map(ct => ct.nodeId.toString()),
|
|
...getSelectedChainNodeIds().map(id => id.toString())
|
|
]}
|
|
onSelectionChange={(keys) => {
|
|
const selectedIds = Array.from(keys).map(key => parseInt(key as string));
|
|
const currentOutNodes = form.outNodeId || [];
|
|
|
|
let protocol = 'tls';
|
|
let strategy = 'round';
|
|
if (currentOutNodes.length > 0) {
|
|
protocol = currentOutNodes[0].protocol || 'tls';
|
|
strategy = currentOutNodes[0].strategy || 'round';
|
|
}
|
|
|
|
const realNodes = currentOutNodes.filter(ct => ct.nodeId !== -1);
|
|
const newOutNodeId: ChainTunnel[] = selectedIds.map(nodeId => {
|
|
const existing = realNodes.find(ct => ct.nodeId === nodeId);
|
|
return existing || { nodeId, chainType: 3, protocol, strategy };
|
|
});
|
|
setForm(prev => ({ ...prev, outNodeId: newOutNodeId }));
|
|
}}
|
|
isInvalid={!!errors.outNodeId}
|
|
errorMessage={errors.outNodeId}
|
|
variant="bordered"
|
|
isDisabled={isEdit}
|
|
classNames={{
|
|
label: "text-xs",
|
|
value: "text-sm"
|
|
}}
|
|
>
|
|
{nodes.map((node) => (
|
|
<SelectItem
|
|
key={node.id}
|
|
textValue={`${node.name}`}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span>{node.name}</span>
|
|
<div className="flex items-center gap-2">
|
|
<Chip
|
|
color={node.status === 1 ? 'success' : 'default'}
|
|
variant="flat"
|
|
size="sm"
|
|
>
|
|
{node.status === 1 ? '在线' : '离线'}
|
|
</Chip>
|
|
{form.inNodeId.some(ct => ct.nodeId === node.id) && (
|
|
<Chip color="warning" variant="flat" size="sm">
|
|
已选为入口
|
|
</Chip>
|
|
)}
|
|
{getSelectedChainNodeIds().includes(node.id) && (
|
|
<Chip color="primary" variant="flat" size="sm">
|
|
已选为转发链
|
|
</Chip>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 协议选择 - 25% */}
|
|
<Select
|
|
label="协议"
|
|
placeholder="选择协议"
|
|
selectedKeys={[(() => {
|
|
if (!form.outNodeId || form.outNodeId.length === 0) return 'tls';
|
|
return form.outNodeId[0].protocol || 'tls';
|
|
})()]}
|
|
onSelectionChange={(keys) => {
|
|
const selectedKey = Array.from(keys)[0] as string;
|
|
if (selectedKey) {
|
|
setForm(prev => {
|
|
const currentOutNodes = prev.outNodeId || [];
|
|
const currentStrategy = currentOutNodes.length > 0 ? currentOutNodes[0].strategy || 'round' : 'round';
|
|
|
|
if (currentOutNodes.length === 0) {
|
|
// 如果还没有出口节点,创建一个占位节点保存设置
|
|
return {
|
|
...prev,
|
|
outNodeId: [{ nodeId: -1, chainType: 3, protocol: selectedKey, strategy: currentStrategy }]
|
|
};
|
|
}
|
|
// 更新所有出口节点的协议
|
|
return {
|
|
...prev,
|
|
outNodeId: currentOutNodes.map(ct => ({ ...ct, protocol: selectedKey }))
|
|
};
|
|
});
|
|
}
|
|
}}
|
|
isInvalid={!!errors.protocol}
|
|
errorMessage={errors.protocol}
|
|
variant="bordered"
|
|
isDisabled={isEdit}
|
|
classNames={{
|
|
label: "text-xs",
|
|
value: "text-sm"
|
|
}}
|
|
>
|
|
<SelectItem key="tls">TLS</SelectItem>
|
|
<SelectItem key="wss">WSS</SelectItem>
|
|
<SelectItem key="tcp">TCP</SelectItem>
|
|
<SelectItem key="mtls">MTLS</SelectItem>
|
|
<SelectItem key="mwss">MWSS</SelectItem>
|
|
<SelectItem key="mtcp">MTCP</SelectItem>
|
|
</Select>
|
|
|
|
{/* 负载策略 - 25% */}
|
|
<Select
|
|
label="负载策略"
|
|
placeholder="选择策略"
|
|
selectedKeys={[(() => {
|
|
if (!form.outNodeId || form.outNodeId.length === 0) return 'round';
|
|
return form.outNodeId[0].strategy || 'round';
|
|
})()]}
|
|
onSelectionChange={(keys) => {
|
|
const selectedKey = Array.from(keys)[0] as string;
|
|
if (selectedKey) {
|
|
setForm(prev => {
|
|
const currentOutNodes = prev.outNodeId || [];
|
|
const currentProtocol = currentOutNodes.length > 0 ? currentOutNodes[0].protocol || 'tls' : 'tls';
|
|
|
|
if (currentOutNodes.length === 0) {
|
|
return {
|
|
...prev,
|
|
outNodeId: [{ nodeId: -1, chainType: 3, protocol: currentProtocol, strategy: selectedKey }]
|
|
};
|
|
}
|
|
return {
|
|
...prev,
|
|
outNodeId: currentOutNodes.map(ct => ({ ...ct, strategy: selectedKey }))
|
|
};
|
|
});
|
|
}
|
|
}}
|
|
variant="bordered"
|
|
isDisabled={isEdit}
|
|
classNames={{
|
|
label: "text-xs",
|
|
value: "text-sm"
|
|
}}
|
|
>
|
|
<SelectItem key="fifo">主备</SelectItem>
|
|
<SelectItem key="round">轮询</SelectItem>
|
|
<SelectItem key="rand">随机</SelectItem>
|
|
</Select>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</ModalBody>
|
|
<ModalFooter>
|
|
<Button variant="light" onPress={onClose}>
|
|
取消
|
|
</Button>
|
|
<Button
|
|
color="primary"
|
|
onPress={handleSubmit}
|
|
isLoading={submitLoading}
|
|
>
|
|
{submitLoading ? (isEdit ? '更新中...' : '创建中...') : (isEdit ? '更新' : '创建')}
|
|
</Button>
|
|
</ModalFooter>
|
|
</>
|
|
)}
|
|
</ModalContent>
|
|
</Modal>
|
|
|
|
{/* 删除确认模态框 */}
|
|
<Modal
|
|
isOpen={deleteModalOpen}
|
|
onOpenChange={setDeleteModalOpen}
|
|
size="2xl"
|
|
scrollBehavior="outside"
|
|
backdrop="blur"
|
|
placement="center"
|
|
>
|
|
<ModalContent>
|
|
{(onClose) => (
|
|
<>
|
|
<ModalHeader className="flex flex-col gap-1">
|
|
<h2 className="text-xl font-bold">确认删除</h2>
|
|
</ModalHeader>
|
|
<ModalBody>
|
|
<p>确定要删除隧道 <strong>"{tunnelToDelete?.name}"</strong> 吗?</p>
|
|
<p className="text-small text-default-500">此操作不可恢复,请谨慎操作。</p>
|
|
</ModalBody>
|
|
<ModalFooter>
|
|
<Button variant="light" onPress={onClose}>
|
|
取消
|
|
</Button>
|
|
<Button
|
|
color="danger"
|
|
onPress={confirmDelete}
|
|
isLoading={deleteLoading}
|
|
>
|
|
{deleteLoading ? '删除中...' : '确认删除'}
|
|
</Button>
|
|
</ModalFooter>
|
|
</>
|
|
)}
|
|
</ModalContent>
|
|
</Modal>
|
|
|
|
{/* 诊断结果模态框 */}
|
|
<Modal
|
|
isOpen={diagnosisModalOpen}
|
|
onOpenChange={setDiagnosisModalOpen}
|
|
size="4xl"
|
|
scrollBehavior="inside"
|
|
backdrop="blur"
|
|
placement="center"
|
|
classNames={{
|
|
base: "rounded-2xl",
|
|
header: "rounded-t-2xl",
|
|
body: "rounded-none",
|
|
footer: "rounded-b-2xl"
|
|
}}
|
|
>
|
|
<ModalContent>
|
|
{(onClose) => (
|
|
<>
|
|
<ModalHeader className="flex flex-col gap-1 bg-content1 border-b border-divider">
|
|
<h2 className="text-xl font-bold">隧道诊断结果</h2>
|
|
{currentDiagnosisTunnel && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-small text-default-500">{currentDiagnosisTunnel.name}</span>
|
|
<Chip
|
|
color={currentDiagnosisTunnel.type === 1 ? 'primary' : 'secondary'}
|
|
variant="flat"
|
|
size="sm"
|
|
>
|
|
{currentDiagnosisTunnel.type === 1 ? '端口转发' : '隧道转发'}
|
|
</Chip>
|
|
</div>
|
|
)}
|
|
</ModalHeader>
|
|
<ModalBody className="bg-content1">
|
|
{diagnosisLoading ? (
|
|
<div className="flex items-center justify-center py-16">
|
|
<div className="flex items-center gap-3">
|
|
<Spinner size="sm" />
|
|
<span className="text-default-600">正在诊断...</span>
|
|
</div>
|
|
</div>
|
|
) : diagnosisResult ? (
|
|
<div className="space-y-4">
|
|
{/* 统计摘要 */}
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div className="text-center p-3 bg-default-100 dark:bg-gray-800 rounded-lg border border-divider">
|
|
<div className="text-2xl font-bold text-foreground">{diagnosisResult.results.length}</div>
|
|
<div className="text-xs text-default-500 mt-1">总测试数</div>
|
|
</div>
|
|
<div className="text-center p-3 bg-success-50 dark:bg-success-900/20 rounded-lg border border-success-200 dark:border-success-700">
|
|
<div className="text-2xl font-bold text-success-600 dark:text-success-400">
|
|
{diagnosisResult.results.filter(r => r.success).length}
|
|
</div>
|
|
<div className="text-xs text-success-600 dark:text-success-400/80 mt-1">成功</div>
|
|
</div>
|
|
<div className="text-center p-3 bg-danger-50 dark:bg-danger-900/20 rounded-lg border border-danger-200 dark:border-danger-700">
|
|
<div className="text-2xl font-bold text-danger-600 dark:text-danger-400">
|
|
{diagnosisResult.results.filter(r => !r.success).length}
|
|
</div>
|
|
<div className="text-xs text-danger-600 dark:text-danger-400/80 mt-1">失败</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 桌面端表格展示 */}
|
|
<div className="hidden md:block space-y-3">
|
|
{(() => {
|
|
// 使用后端返回的 chainType 和 inx 字段进行分组
|
|
const groupedResults = {
|
|
entry: diagnosisResult.results.filter(r => r.fromChainType === 1),
|
|
chains: {} as Record<number, typeof diagnosisResult.results>,
|
|
exit: diagnosisResult.results.filter(r => r.fromChainType === 3)
|
|
};
|
|
|
|
// 按 inx 分组链路测试
|
|
diagnosisResult.results.forEach(r => {
|
|
if (r.fromChainType === 2 && r.fromInx != null) {
|
|
if (!groupedResults.chains[r.fromInx]) {
|
|
groupedResults.chains[r.fromInx] = [];
|
|
}
|
|
groupedResults.chains[r.fromInx].push(r);
|
|
}
|
|
});
|
|
|
|
const renderTableSection = (title: string, results: typeof diagnosisResult.results) => {
|
|
if (results.length === 0) return null;
|
|
|
|
return (
|
|
<div key={title} className="border border-divider rounded-lg overflow-hidden bg-white dark:bg-gray-800">
|
|
<div className="bg-primary/10 dark:bg-primary/20 px-3 py-2 border-b border-divider">
|
|
<h3 className="text-sm font-semibold text-primary">{title}</h3>
|
|
</div>
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-default-100 dark:bg-gray-700">
|
|
<tr>
|
|
<th className="px-3 py-2 text-left font-semibold text-xs">路径</th>
|
|
<th className="px-3 py-2 text-center font-semibold text-xs w-20">状态</th>
|
|
<th className="px-3 py-2 text-center font-semibold text-xs w-24">延迟(ms)</th>
|
|
<th className="px-3 py-2 text-center font-semibold text-xs w-24">丢包率</th>
|
|
<th className="px-3 py-2 text-center font-semibold text-xs w-20">质量</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-divider bg-white dark:bg-gray-800">
|
|
{results.map((result, index) => {
|
|
const quality = getQualityDisplay(result.averageTime, result.packetLoss);
|
|
|
|
return (
|
|
<tr key={index} className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${
|
|
result.success ? 'bg-white dark:bg-gray-800' : 'bg-danger-50 dark:bg-danger-900/30'
|
|
}`}>
|
|
<td className="px-3 py-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
|
|
result.success
|
|
? 'bg-success text-white'
|
|
: 'bg-danger text-white'
|
|
}`}>
|
|
{result.success ? '✓' : '✗'}
|
|
</span>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium text-foreground truncate">{result.description}</div>
|
|
<div className="text-xs text-default-500 truncate">
|
|
{result.targetIp}:{result.targetPort}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-3 py-2 text-center">
|
|
<Chip
|
|
color={result.success ? 'success' : 'danger'}
|
|
variant="flat"
|
|
size="sm"
|
|
className="min-w-[50px]"
|
|
>
|
|
{result.success ? '成功' : '失败'}
|
|
</Chip>
|
|
</td>
|
|
<td className="px-3 py-2 text-center">
|
|
{result.success ? (
|
|
<span className="font-semibold text-primary">
|
|
{result.averageTime?.toFixed(0)}
|
|
</span>
|
|
) : (
|
|
<span className="text-default-400">-</span>
|
|
)}
|
|
</td>
|
|
<td className="px-3 py-2 text-center">
|
|
{result.success ? (
|
|
<span className={`font-semibold ${
|
|
(result.packetLoss || 0) > 0 ? 'text-warning' : 'text-success'
|
|
}`}>
|
|
{result.packetLoss?.toFixed(1)}%
|
|
</span>
|
|
) : (
|
|
<span className="text-default-400">-</span>
|
|
)}
|
|
</td>
|
|
<td className="px-3 py-2 text-center">
|
|
{result.success && quality ? (
|
|
<Chip
|
|
color={quality.color as any}
|
|
variant="flat"
|
|
size="sm"
|
|
className="text-xs"
|
|
>
|
|
{quality.text}
|
|
</Chip>
|
|
) : (
|
|
<span className="text-default-400">-</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* 入口测试 */}
|
|
{renderTableSection('🚪 入口测试', groupedResults.entry)}
|
|
|
|
{/* 链路测试(按跳数排序) */}
|
|
{Object.keys(groupedResults.chains)
|
|
.map(Number)
|
|
.sort((a, b) => a - b)
|
|
.map(hop => renderTableSection(`🔗 转发链 - 第${hop}跳`, groupedResults.chains[hop]))}
|
|
|
|
{/* 出口测试 */}
|
|
{renderTableSection('🚀 出口测试', groupedResults.exit)}
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
|
|
{/* 移动端卡片展示 */}
|
|
<div className="md:hidden space-y-3">
|
|
{(() => {
|
|
// 使用后端返回的 chainType 和 inx 字段进行分组
|
|
const groupedResults = {
|
|
entry: diagnosisResult.results.filter(r => r.fromChainType === 1),
|
|
chains: {} as Record<number, typeof diagnosisResult.results>,
|
|
exit: diagnosisResult.results.filter(r => r.fromChainType === 3)
|
|
};
|
|
|
|
// 按 inx 分组链路测试
|
|
diagnosisResult.results.forEach(r => {
|
|
if (r.fromChainType === 2 && r.fromInx != null) {
|
|
if (!groupedResults.chains[r.fromInx]) {
|
|
groupedResults.chains[r.fromInx] = [];
|
|
}
|
|
groupedResults.chains[r.fromInx].push(r);
|
|
}
|
|
});
|
|
|
|
const renderCardSection = (title: string, results: typeof diagnosisResult.results) => {
|
|
if (results.length === 0) return null;
|
|
|
|
return (
|
|
<div key={title} className="space-y-2">
|
|
<div className="px-2 py-1.5 bg-primary/10 dark:bg-primary/20 rounded-lg border border-primary/30">
|
|
<h3 className="text-sm font-semibold text-primary">{title}</h3>
|
|
</div>
|
|
{results.map((result, index) => {
|
|
const quality = getQualityDisplay(result.averageTime, result.packetLoss);
|
|
|
|
return (
|
|
<div key={index} className={`border rounded-lg p-3 ${
|
|
result.success
|
|
? 'border-divider bg-white dark:bg-gray-800'
|
|
: 'border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30'
|
|
}`}>
|
|
<div className="flex items-start gap-2 mb-2">
|
|
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
|
|
result.success ? 'bg-success text-white' : 'bg-danger text-white'
|
|
}`}>
|
|
{result.success ? '✓' : '✗'}
|
|
</span>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-semibold text-sm text-foreground break-words">
|
|
{result.description}
|
|
</div>
|
|
<div className="text-xs text-default-500 mt-0.5 break-all">
|
|
{result.targetIp}:{result.targetPort}
|
|
</div>
|
|
</div>
|
|
<Chip
|
|
color={result.success ? 'success' : 'danger'}
|
|
variant="flat"
|
|
size="sm"
|
|
className="flex-shrink-0"
|
|
>
|
|
{result.success ? '成功' : '失败'}
|
|
</Chip>
|
|
</div>
|
|
|
|
{result.success ? (
|
|
<div className="grid grid-cols-3 gap-2 mt-2 pt-2 border-t border-divider">
|
|
<div className="text-center">
|
|
<div className="text-lg font-bold text-primary">
|
|
{result.averageTime?.toFixed(0)}
|
|
</div>
|
|
<div className="text-xs text-default-500">延迟(ms)</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className={`text-lg font-bold ${
|
|
(result.packetLoss || 0) > 0 ? 'text-warning' : 'text-success'
|
|
}`}>
|
|
{result.packetLoss?.toFixed(1)}%
|
|
</div>
|
|
<div className="text-xs text-default-500">丢包率</div>
|
|
</div>
|
|
<div className="text-center">
|
|
{quality && (
|
|
<>
|
|
<Chip
|
|
color={quality.color as any}
|
|
variant="flat"
|
|
size="sm"
|
|
className="text-xs"
|
|
>
|
|
{quality.text}
|
|
</Chip>
|
|
<div className="text-xs text-default-500 mt-0.5">质量</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="mt-2 pt-2 border-t border-divider">
|
|
<div className="text-xs text-danger">
|
|
{result.message || '连接失败'}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* 入口测试 */}
|
|
{renderCardSection('🚪 入口测试', groupedResults.entry)}
|
|
|
|
{/* 链路测试(按跳数排序) */}
|
|
{Object.keys(groupedResults.chains)
|
|
.map(Number)
|
|
.sort((a, b) => a - b)
|
|
.map(hop => renderCardSection(`🔗 转发链 - 第${hop}跳`, groupedResults.chains[hop]))}
|
|
|
|
{/* 出口测试 */}
|
|
{renderCardSection('🚀 出口测试', groupedResults.exit)}
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
|
|
{/* 失败详情(仅桌面端显示,移动端已在卡片中显示) */}
|
|
{diagnosisResult.results.some(r => !r.success) && (
|
|
<div className="space-y-2 hidden md:block">
|
|
<h4 className="text-sm font-semibold text-danger">失败详情</h4>
|
|
<div className="space-y-2">
|
|
{diagnosisResult.results.filter(r => !r.success).map((result, index) => (
|
|
<Alert
|
|
key={index}
|
|
color="danger"
|
|
variant="flat"
|
|
title={result.description}
|
|
description={result.message || '连接失败'}
|
|
className="text-xs"
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-16">
|
|
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
<svg className="w-8 h-8 text-default-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-foreground">暂无诊断数据</h3>
|
|
</div>
|
|
)}
|
|
</ModalBody>
|
|
<ModalFooter className="bg-content1 border-t border-divider">
|
|
<Button variant="light" onPress={onClose}>
|
|
关闭
|
|
</Button>
|
|
{currentDiagnosisTunnel && (
|
|
<Button
|
|
color="primary"
|
|
onPress={() => handleDiagnose(currentDiagnosisTunnel)}
|
|
isLoading={diagnosisLoading}
|
|
>
|
|
重新诊断
|
|
</Button>
|
|
)}
|
|
</ModalFooter>
|
|
</>
|
|
)}
|
|
</ModalContent>
|
|
</Modal>
|
|
</div>
|
|
|
|
);
|
|
}
|