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:
attacker committed 2026-06-13 23:34:23 +08:00
1 parent 7d6674bddd
commit 97c4207ff8
132 files changed
+1572 -34

No files matched your search

+730
View File
@@ -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>
);
}