refactor: 标准化项目结构 frontend/ + backend/ + SQLite + run.sh
- 目录重构: app/ -> frontend/,与 backend/ 并列 - 数据库: 默认 SQLite(零配置),修复 DSN() bug - run.sh: 一键启动前后端,支持 start/stop/restart/status - Makefile: install/dev/build/lint/clean 快捷命令 - vite.config.ts: 配置 dev server 端口 5173 + API 代理到 8080 - config.yaml: SQLite 默认配置,开箱即用 - docs/: 完整开发文档(项目结构/API/数据库/规范/部署) - 修复: GORM foreign key 关联声明 - 修复: go get gorm.io/driver/sqlite 依赖
This commit is contained in:
1 parent
7d6674bddd
commit
97c4207ff8
132 files changed
+1572
-34
No files matched your search
@@ -0,0 +1,730 @@
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Cloud,
|
||||
Plus,
|
||||
MoreHorizontal,
|
||||
Server,
|
||||
Cpu,
|
||||
Database,
|
||||
Activity,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
Eye,
|
||||
Trash2,
|
||||
Edit,
|
||||
Terminal,
|
||||
Search,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import { k8sClusters, k8sNodes, k8sPods, k8sDeployments, k8sServices, k8sIngresses } from '@/mocks/data';
|
||||
import type { K8sCluster } from '@/types';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
|
||||
export function K8sClusters() {
|
||||
const [selectedCluster, setSelectedCluster] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const filteredClusters = k8sClusters.filter(
|
||||
(cluster) =>
|
||||
cluster.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
cluster.version.includes(searchQuery)
|
||||
);
|
||||
|
||||
const getStatusBadge = (status: K8sCluster['status']) => {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return (
|
||||
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
健康
|
||||
</Badge>
|
||||
);
|
||||
case 'warning':
|
||||
return (
|
||||
<Badge variant="outline" className="border-amber-500/30 text-amber-400 bg-amber-500/10">
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
警告
|
||||
</Badge>
|
||||
);
|
||||
case 'error':
|
||||
return (
|
||||
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
异常
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const clusterDetail = selectedCluster
|
||||
? k8sClusters.find((c) => c.id === selectedCluster)
|
||||
: null;
|
||||
const clusterNodes = selectedCluster
|
||||
? k8sNodes.filter((n) => n.clusterId === selectedCluster)
|
||||
: [];
|
||||
const clusterPods = selectedCluster
|
||||
? k8sPods.filter((p) => p.clusterId === selectedCluster)
|
||||
: [];
|
||||
|
||||
// 模拟资源使用趋势数据
|
||||
const resourceData = [
|
||||
{ time: '00:00', cpu: 45, memory: 52 },
|
||||
{ time: '04:00', cpu: 38, memory: 48 },
|
||||
{ time: '08:00', cpu: 62, memory: 68 },
|
||||
{ time: '12:00', cpu: 78, memory: 82 },
|
||||
{ time: '16:00', cpu: 72, memory: 76 },
|
||||
{ time: '20:00', cpu: 58, memory: 64 },
|
||||
{ time: '24:00', cpu: 48, memory: 56 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-100">K8s集群管理</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">管理Kubernetes集群资源</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加集群
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">集群总数</p>
|
||||
<p className="text-2xl font-bold text-slate-100">{k8sClusters.length}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
|
||||
<Cloud className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">健康集群</p>
|
||||
<p className="text-2xl font-bold text-emerald-400">
|
||||
{k8sClusters.filter((c) => c.status === 'healthy').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">总节点数</p>
|
||||
<p className="text-2xl font-bold text-slate-100">
|
||||
{k8sClusters.reduce((acc, c) => acc + c.nodes, 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-purple-500/10 text-purple-400">
|
||||
<Server className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">总Pod数</p>
|
||||
<p className="text-2xl font-bold text-slate-100">
|
||||
{k8sClusters.reduce((acc, c) => acc + c.pods, 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-cyan-500/10 text-cyan-400">
|
||||
<Database className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-80">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder="搜索集群名称或版本..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clusters Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{filteredClusters.map((cluster) => (
|
||||
<Card
|
||||
key={cluster.id}
|
||||
className={cn(
|
||||
'bg-slate-900 border-slate-800 cursor-pointer transition-all',
|
||||
selectedCluster === cluster.id
|
||||
? 'border-blue-500 ring-1 ring-blue-500/30'
|
||||
: 'hover:border-slate-700'
|
||||
)}
|
||||
onClick={() => setSelectedCluster(cluster.id)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
|
||||
<Cloud className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base text-slate-100">{cluster.name}</CardTitle>
|
||||
<CardDescription className="text-slate-500">{cluster.version}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{getStatusBadge(cluster.status)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-slate-100"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48 bg-slate-800 border-slate-700">
|
||||
<DropdownMenuLabel className="text-slate-300">操作</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-slate-700" />
|
||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
查看详情
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
Kubectl
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
配置
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="bg-slate-700" />
|
||||
<DropdownMenuItem className="text-red-400 focus:bg-slate-700 focus:text-red-400">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="grid grid-cols-4 gap-2 mb-4">
|
||||
<div className="text-center p-2 rounded-lg bg-slate-800/50">
|
||||
<div className="text-lg font-semibold text-slate-200">{cluster.nodes}</div>
|
||||
<div className="text-xs text-slate-500">节点</div>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded-lg bg-slate-800/50">
|
||||
<div className="text-lg font-semibold text-slate-200">{cluster.pods}</div>
|
||||
<div className="text-xs text-slate-500">Pod</div>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded-lg bg-slate-800/50">
|
||||
<div className="text-lg font-semibold text-slate-200">{cluster.services}</div>
|
||||
<div className="text-xs text-slate-500">Service</div>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded-lg bg-slate-800/50">
|
||||
<div className="text-lg font-semibold text-slate-200">{cluster.deployments}</div>
|
||||
<div className="text-xs text-slate-500">Deployment</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 w-10">CPU</span>
|
||||
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
cluster.cpuUsage > 80 ? 'bg-red-400' : cluster.cpuUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
|
||||
)}
|
||||
style={{ width: `${cluster.cpuUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400 w-10 text-right">{cluster.cpuUsage.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 w-10">内存</span>
|
||||
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
cluster.memoryUsage > 80 ? 'bg-red-400' : cluster.memoryUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
|
||||
)}
|
||||
style={{ width: `${cluster.memoryUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400 w-10 text-right">{cluster.memoryUsage.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Cluster Detail */}
|
||||
{clusterDetail && (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Cloud className="h-6 w-6 text-blue-400" />
|
||||
<div>
|
||||
<CardTitle className="text-slate-100">{clusterDetail.name}</CardTitle>
|
||||
<CardDescription className="text-slate-500">
|
||||
{clusterDetail.version} · {clusterDetail.nodes} 节点 · 创建于{' '}
|
||||
{new Date(clusterDetail.createdAt).toLocaleDateString()}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-slate-400 hover:text-slate-100"
|
||||
onClick={() => setSelectedCluster(null)}
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="overview" className="w-full">
|
||||
<TabsList className="bg-slate-800 border border-slate-700 flex-wrap h-auto">
|
||||
<TabsTrigger value="overview" className="data-[state=active]:bg-slate-700">
|
||||
概览
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="nodes" className="data-[state=active]:bg-slate-700">
|
||||
节点 ({clusterNodes.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="pods" className="data-[state=active]:bg-slate-700">
|
||||
Pod ({clusterPods.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="deployments" className="data-[state=active]:bg-slate-700">
|
||||
Deployment ({k8sDeployments.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="services" className="data-[state=active]:bg-slate-700">
|
||||
Service ({k8sServices.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="ingresses" className="data-[state=active]:bg-slate-700">
|
||||
Ingress ({k8sIngresses.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="events" className="data-[state=active]:bg-slate-700">
|
||||
事件
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="mt-4 space-y-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card className="bg-slate-800/50 border-slate-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-slate-400">资源使用趋势</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={resourceData}>
|
||||
<defs>
|
||||
<linearGradient id="colorCpu" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorMemory" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||
<XAxis dataKey="time" stroke="#475569" fontSize={12} />
|
||||
<YAxis stroke="#475569" fontSize={12} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#0f172a',
|
||||
border: '1px solid #1e293b',
|
||||
borderRadius: '6px',
|
||||
color: '#f8fafc',
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cpu"
|
||||
stroke="#3b82f6"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorCpu)"
|
||||
name="CPU"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="memory"
|
||||
stroke="#10b981"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorMemory)"
|
||||
name="内存"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-slate-800/50 border-slate-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-slate-400">资源分布</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="text-center p-4 rounded-lg bg-slate-800">
|
||||
<div className="text-3xl font-bold text-blue-400">{clusterDetail.pods}</div>
|
||||
<div className="text-sm text-slate-500 mt-1">Pod</div>
|
||||
</div>
|
||||
<div className="text-center p-4 rounded-lg bg-slate-800">
|
||||
<div className="text-3xl font-bold text-emerald-400">{clusterDetail.services}</div>
|
||||
<div className="text-sm text-slate-500 mt-1">Service</div>
|
||||
</div>
|
||||
<div className="text-center p-4 rounded-lg bg-slate-800">
|
||||
<div className="text-3xl font-bold text-purple-400">{clusterDetail.deployments}</div>
|
||||
<div className="text-sm text-slate-500 mt-1">Deployment</div>
|
||||
</div>
|
||||
<div className="text-center p-4 rounded-lg bg-slate-800">
|
||||
<div className="text-3xl font-bold text-amber-400">{clusterDetail.nodes}</div>
|
||||
<div className="text-sm text-slate-500 mt-1">节点</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="nodes" className="mt-4">
|
||||
<div className="border rounded-lg border-slate-700 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-800">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">名称</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">角色</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">状态</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">CPU</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">内存</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">Pod</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">操作系统</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{clusterNodes.map((node) => (
|
||||
<tr key={node.id} className="hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 text-sm text-slate-200">{node.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
node.role === 'master'
|
||||
? 'border-purple-500/30 text-purple-400 bg-purple-500/10'
|
||||
: 'border-blue-500/30 text-blue-400 bg-blue-500/10'
|
||||
}
|
||||
>
|
||||
{node.role === 'master' ? 'Master' : 'Worker'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
node.status === 'ready'
|
||||
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
: 'border-red-500/30 text-red-400 bg-red-500/10'
|
||||
}
|
||||
>
|
||||
{node.status === 'ready' ? 'Ready' : 'NotReady'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">
|
||||
{node.cpuUsage.toFixed(1)}% / {node.cpu}核
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">
|
||||
{node.memoryUsage.toFixed(1)}% / {node.memory}GB
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{node.pods}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-400">{node.os}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pods" className="mt-4">
|
||||
<div className="border rounded-lg border-slate-700 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-800">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">名称</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">命名空间</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">状态</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">重启次数</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">CPU</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">内存</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">节点</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{clusterPods.map((pod) => (
|
||||
<tr key={pod.id} className="hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 text-sm text-slate-200">{pod.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">
|
||||
{pod.namespace}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
pod.status === 'running'
|
||||
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
: pod.status === 'pending'
|
||||
? 'border-amber-500/30 text-amber-400 bg-amber-500/10'
|
||||
: 'border-red-500/30 text-red-400 bg-red-500/10'
|
||||
}
|
||||
>
|
||||
{pod.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{pod.restarts}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{pod.cpu}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{pod.memory}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-400">{pod.node}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="deployments" className="mt-4">
|
||||
<div className="border rounded-lg border-slate-700 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-800">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">名称</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">命名空间</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">副本数</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">可用副本</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">镜像</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">创建时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{k8sDeployments.map((deployment) => (
|
||||
<tr key={deployment.id} className="hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 text-sm text-slate-200">{deployment.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">
|
||||
{deployment.namespace}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{deployment.replicas}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-sm ${deployment.availableReplicas === deployment.replicas ? 'text-emerald-400' : 'text-amber-400'}`}>
|
||||
{deployment.availableReplicas} / {deployment.replicas}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-400 truncate max-w-[200px]">{deployment.image}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-400">{deployment.createdAt}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="services" className="mt-4">
|
||||
<div className="border rounded-lg border-slate-700 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-800">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">名称</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">命名空间</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">类型</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">ClusterIP</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">端口</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">Selector</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{k8sServices.map((service) => (
|
||||
<tr key={service.id} className="hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 text-sm text-slate-200">{service.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">
|
||||
{service.namespace}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
service.type === 'ClusterIP'
|
||||
? 'border-blue-500/30 text-blue-400 bg-blue-500/10'
|
||||
: service.type === 'NodePort'
|
||||
? 'border-purple-500/30 text-purple-400 bg-purple-500/10'
|
||||
: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
}
|
||||
>
|
||||
{service.type}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{service.clusterIP}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{service.ports}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-400">{service.selector}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ingresses" className="mt-4">
|
||||
<div className="border rounded-lg border-slate-700 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-800">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">名称</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">命名空间</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">Hosts</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">后端服务</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">TLS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{k8sIngresses.map((ingress) => (
|
||||
<tr key={ingress.id} className="hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 text-sm text-slate-200">{ingress.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">
|
||||
{ingress.namespace}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{ingress.hosts}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{ingress.backend}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
ingress.tls
|
||||
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
: 'border-slate-700 text-slate-400'
|
||||
}
|
||||
>
|
||||
{ingress.tls ? '已启用' : '未启用'}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="events" className="mt-4">
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ type: 'Normal', reason: 'Started', message: 'Started container nginx', time: '2分钟前' },
|
||||
{ type: 'Normal', reason: 'Created', message: 'Created pod nginx-deployment-xxx', time: '5分钟前' },
|
||||
{ type: 'Warning', reason: 'FailedScheduling', message: '0/3 nodes are available', time: '10分钟前' },
|
||||
{ type: 'Normal', reason: 'Pulling', message: 'Pulling image "nginx:latest"', time: '15分钟前' },
|
||||
{ type: 'Normal', reason: 'Pulled', message: 'Successfully pulled image', time: '12分钟前' },
|
||||
].map((event, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-slate-800/50"
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
event.type === 'Normal'
|
||||
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
: 'border-amber-500/30 text-amber-400 bg-amber-500/10'
|
||||
}
|
||||
>
|
||||
{event.type}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium text-slate-300 min-w-[120px]">{event.reason}</span>
|
||||
<span className="text-sm text-slate-400 flex-1">{event.message}</span>
|
||||
<span className="text-xs text-slate-500">{event.time}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Network,
|
||||
Plus,
|
||||
Search,
|
||||
Server,
|
||||
Database,
|
||||
Layers,
|
||||
Share2,
|
||||
Shield,
|
||||
HardDrive,
|
||||
Cloud,
|
||||
RefreshCw,
|
||||
Download,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Move,
|
||||
Edit,
|
||||
Trash2,
|
||||
Save,
|
||||
} from 'lucide-react';
|
||||
import { topologyNodes, topologyEdges } from '@/mocks/data';
|
||||
import type { TopologyNode, TopologyEdge } from '@/types';
|
||||
|
||||
export function TopologyView() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const [selectedNode, setSelectedNode] = useState<TopologyNode | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [viewType, setViewType] = useState<string>('all');
|
||||
|
||||
const filteredNodes = topologyNodes.filter(
|
||||
(node) =>
|
||||
node.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
|
||||
(viewType === 'all' || node.type === viewType)
|
||||
);
|
||||
|
||||
const getNodeIcon = (type: TopologyNode['type']) => {
|
||||
const icons = {
|
||||
server: <Server className="h-5 w-5" />,
|
||||
database: <Database className="h-5 w-5" />,
|
||||
cache: <Layers className="h-5 w-5" />,
|
||||
loadbalancer: <Share2 className="h-5 w-5" />,
|
||||
cluster: <Cloud className="h-5 w-5" />,
|
||||
app: <HardDrive className="h-5 w-5" />,
|
||||
service: <Network className="h-5 w-5" />,
|
||||
};
|
||||
return icons[type] || <Server className="h-5 w-5" />;
|
||||
};
|
||||
|
||||
const getNodeColor = (type: TopologyNode['type']) => {
|
||||
const colors = {
|
||||
server: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
database: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
|
||||
cache: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||
loadbalancer: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
|
||||
cluster: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30',
|
||||
app: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
|
||||
service: 'bg-slate-500/20 text-slate-400 border-slate-500/30',
|
||||
};
|
||||
return colors[type] || 'bg-slate-500/20 text-slate-400 border-slate-500/30';
|
||||
};
|
||||
|
||||
// 简化的SVG拓扑图渲染
|
||||
const renderTopology = () => {
|
||||
const width = 800;
|
||||
const height = 500;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="bg-slate-950 rounded-lg"
|
||||
>
|
||||
{/* Grid background */}
|
||||
<defs>
|
||||
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#1e293b" strokeWidth="0.5" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
|
||||
{/* Edges */}
|
||||
{topologyEdges.map((edge) => {
|
||||
const source = topologyNodes.find((n) => n.id === edge.source);
|
||||
const target = topologyNodes.find((n) => n.id === edge.target);
|
||||
if (!source || !target) return null;
|
||||
|
||||
return (
|
||||
<g key={edge.id}>
|
||||
<line
|
||||
x1={source.x}
|
||||
y1={source.y}
|
||||
x2={target.x}
|
||||
y2={target.y}
|
||||
stroke="#475569"
|
||||
strokeWidth="2"
|
||||
markerEnd="url(#arrowhead)"
|
||||
/>
|
||||
<text
|
||||
x={(source.x + target.x) / 2}
|
||||
y={(source.y + target.y) / 2 - 5}
|
||||
fill="#64748b"
|
||||
fontSize="10"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{edge.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Arrow marker */}
|
||||
<defs>
|
||||
<marker
|
||||
id="arrowhead"
|
||||
markerWidth="10"
|
||||
markerHeight="7"
|
||||
refX="28"
|
||||
refY="3.5"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#475569" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* Nodes */}
|
||||
{filteredNodes.map((node) => (
|
||||
<g
|
||||
key={node.id}
|
||||
transform={`translate(${node.x}, ${node.y})`}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setSelectedNode(node)}
|
||||
>
|
||||
<circle
|
||||
r="28"
|
||||
fill="#0f172a"
|
||||
stroke={selectedNode?.id === node.id ? '#3b82f6' : '#334155'}
|
||||
strokeWidth={selectedNode?.id === node.id ? 3 : 2}
|
||||
className="transition-all hover:stroke-blue-500"
|
||||
/>
|
||||
<foreignObject x="-12" y="-12" width="24" height="24">
|
||||
<div className={`flex items-center justify-center w-full h-full ${getNodeColor(node.type).split(' ')[1]}`}>
|
||||
{getNodeIcon(node.type)}
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text
|
||||
y="45"
|
||||
fill="#94a3b8"
|
||||
fontSize="12"
|
||||
textAnchor="middle"
|
||||
className="font-medium"
|
||||
>
|
||||
{node.name}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-100">架构拓扑</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">系统架构可视化与依赖关系管理</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
导出
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加节点
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">节点数</p>
|
||||
<p className="text-2xl font-bold text-slate-100">{topologyNodes.length}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
|
||||
<Server className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">连接数</p>
|
||||
<p className="text-2xl font-bold text-slate-100">{topologyEdges.length}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-purple-500/10 text-purple-400">
|
||||
<Network className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">数据库</p>
|
||||
<p className="text-2xl font-bold text-emerald-400">
|
||||
{topologyNodes.filter((n) => n.type === 'database').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
|
||||
<Database className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">缓存</p>
|
||||
<p className="text-2xl font-bold text-amber-400">
|
||||
{topologyNodes.filter((n) => n.type === 'cache').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-amber-500/10 text-amber-400">
|
||||
<Layers className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">负载均衡</p>
|
||||
<p className="text-2xl font-bold text-cyan-400">
|
||||
{topologyNodes.filter((n) => n.type === 'loadbalancer').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-cyan-500/10 text-cyan-400">
|
||||
<Share2 className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters & Toolbar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-48">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder="搜索节点..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<Select value={viewType} onValueChange={setViewType}>
|
||||
<SelectTrigger className="w-36 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
|
||||
<SelectValue placeholder="节点类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
<SelectItem value="all">全部类型</SelectItem>
|
||||
<SelectItem value="server">服务器</SelectItem>
|
||||
<SelectItem value="database">数据库</SelectItem>
|
||||
<SelectItem value="cache">缓存</SelectItem>
|
||||
<SelectItem value="loadbalancer">负载均衡</SelectItem>
|
||||
<SelectItem value="cluster">集群</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400">
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400">
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Topology Canvas */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
<div className="lg:col-span-3">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="h-[500px]">
|
||||
{renderTopology()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Node List */}
|
||||
<div>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base text-slate-100">节点列表</CardTitle>
|
||||
<CardDescription className="text-slate-500">点击节点查看详情</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 max-h-[420px] overflow-auto">
|
||||
{filteredNodes.map((node) => (
|
||||
<div
|
||||
key={node.id}
|
||||
className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-colors ${
|
||||
selectedNode?.id === node.id ? 'bg-blue-500/10 border border-blue-500/30' : 'hover:bg-slate-800/50'
|
||||
}`}
|
||||
onClick={() => setSelectedNode(node)}
|
||||
>
|
||||
<div className={`p-2 rounded-lg ${getNodeColor(node.type)}`}>
|
||||
{getNodeIcon(node.type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-200 truncate">{node.name}</p>
|
||||
<p className="text-xs text-slate-500">{node.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Node Detail Dialog */}
|
||||
<Dialog open={!!selectedNode} onOpenChange={() => setSelectedNode(null)}>
|
||||
<DialogContent className="bg-slate-900 border-slate-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
||||
<div className={`p-2 rounded-lg ${getNodeColor(selectedNode?.type || 'server')}`}>
|
||||
{selectedNode && getNodeIcon(selectedNode.type)}
|
||||
</div>
|
||||
{selectedNode?.name}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">
|
||||
类型: {selectedNode?.type}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-slate-800/50">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">节点ID</p>
|
||||
<p className="text-sm text-slate-200">{selectedNode?.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">节点名称</p>
|
||||
<p className="text-sm text-slate-200">{selectedNode?.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">坐标位置</p>
|
||||
<p className="text-sm text-slate-200">X: {selectedNode?.x}, Y: {selectedNode?.y}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">关联连接</p>
|
||||
<p className="text-sm text-slate-200">
|
||||
{topologyEdges.filter((e) => e.source === selectedNode?.id || e.target === selectedNode?.id).length} 条
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedNode?.data && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-300 mb-2">扩展数据</h4>
|
||||
<div className="p-3 rounded-lg bg-slate-800/50">
|
||||
<pre className="text-xs text-slate-400 overflow-auto">
|
||||
{JSON.stringify(selectedNode.data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-300 mb-2">关联节点</h4>
|
||||
<div className="space-y-2">
|
||||
{topologyEdges
|
||||
.filter((e) => e.source === selectedNode?.id || e.target === selectedNode?.id)
|
||||
.map((edge) => {
|
||||
const isSource = edge.source === selectedNode?.id;
|
||||
const relatedNodeId = isSource ? edge.target : edge.source;
|
||||
const relatedNode = topologyNodes.find((n) => n.id === relatedNodeId);
|
||||
if (!relatedNode) return null;
|
||||
|
||||
return (
|
||||
<div key={edge.id} className="flex items-center gap-2 p-2 rounded-lg bg-slate-800/50">
|
||||
<span className="text-sm text-slate-400">{isSource ? '→' : '←'}</span>
|
||||
<div className={`p-1 rounded ${getNodeColor(relatedNode.type)}`}>
|
||||
{getNodeIcon(relatedNode.type)}
|
||||
</div>
|
||||
<span className="text-sm text-slate-300">{relatedNode.name}</span>
|
||||
<Badge variant="outline" className="ml-auto border-slate-700 text-slate-400 text-xs">
|
||||
{edge.label}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300">
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||
<Server className="h-4 w-4 mr-2" />
|
||||
查看资源
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user