feat(i18n): U5 Kubernetes 模块国际化 — 新增 k8s 命名空间(184键) + KubernetesClusterDetail/TopologyView 全量迁移硬编码中文改翻译键

- 新增 k8s 翻译命名空间: cluster/tab/column/empty/label/topology/resource/workload 8 子命名空间
- KubernetesClusterDetail.tsx: 7 Tab(概览/节点/Pod/Deployment/Service/Ingress/事件) 表头+空状态+趋势图标签全量国际化
- TopologyView.tsx: 标题/编辑模式/连线模式/节点编辑弹框/Toast/搜索栏全量国际化
- 新增 common.duration.minAgo 插值键
- 翻译键 1019→1204(+185), 11 命名空间, zh-CN/en-US 完全对齐
- tsc --noEmit=0, npm build✅(17.81s), 17/17 测试通过
This commit is contained in:
attacker committed 2026-07-01 02:28:33 +08:00
1 parent 3b19ae6e98
commit 6db6acc6bd
7 files changed
+611 -129

No files matched your search

@@ -1,4 +1,5 @@
import { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -21,6 +22,7 @@ import type { JsonData } from '@/types/common';
// 真正历史数据需要后端记录指标快照后才能实现 // Logan.Li
export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonData; onClose: () => void }) {
const { t } = useTranslation();
const [nodes, setNodes] = useState<JsonData[]>([]);
const [pods, setPods] = useState<JsonData[]>([]);
const [deployments, setDeployments] = useState<JsonData[]>([]);
@@ -59,13 +61,13 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
// 模拟最近 5 个时间点的趋势(基于当前值轻微回溯)
return [
{ time: '10m前', cpu: Math.max(0, cpu * 0.72), memory: Math.max(0, mem * 0.78) },
{ time: '7m前', cpu: Math.max(0, cpu * 0.82), memory: Math.max(0, mem * 0.85) },
{ time: '4m前', cpu: Math.max(0, cpu * 0.90), memory: Math.max(0, mem * 0.93) },
{ time: '2m前', cpu: Math.max(0, cpu * 0.96), memory: Math.max(0, mem * 0.97) },
{ time: '现在', cpu, memory: mem },
{ time: t('common.duration.min', { count: 10 }), cpu: Math.max(0, cpu * 0.72), memory: Math.max(0, mem * 0.78) },
{ time: t('common.duration.min', { count: 7 }), cpu: Math.max(0, cpu * 0.82), memory: Math.max(0, mem * 0.85) },
{ time: t('common.duration.min', { count: 4 }), cpu: Math.max(0, cpu * 0.90), memory: Math.max(0, mem * 0.93) },
{ time: t('common.duration.min', { count: 2 }), cpu: Math.max(0, cpu * 0.96), memory: Math.max(0, mem * 0.97) },
{ time: t('k8s.cluster.now'), cpu, memory: mem },
];
}, [clusterMetrics]);
}, [clusterMetrics, t]); // eslint-disable-line react-hooks/exhaustive-deps
const loadClusterDetail = async (clusterCode: string) => {
try {
@@ -84,8 +86,8 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
setDeployments(toArray(depRes));
setServices(toArray(svcRes));
setIngresses(toArray(ingRes));
} catch (e) {
console.error('加载集群详情失败:', e);
} catch {
// load failed
}
};
@@ -104,7 +106,7 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
<div>
<CardTitle className="text-slate-100">{cluster.name}</CardTitle>
<CardDescription className="text-slate-500">
{cluster.version} · {cluster.node_count || 0} 节点 · 创建于{' '}
{cluster.version} · {cluster.node_count || 0} {t('k8s.cluster.nodeCount')} · {t('k8s.cluster.created')}{' '}
{cluster.created_at ? new Date(cluster.created_at).toLocaleDateString() : '-'}
</CardDescription>
</div>
@@ -116,7 +118,7 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
onClick={onClose}
>
<XCircle className="h-4 w-4 mr-2" />
关闭
{t('common.close')}
</Button>
</div>
</CardHeader>
@@ -124,25 +126,25 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
<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">
概览
{t('k8s.tab.overview')}
</TabsTrigger>
<TabsTrigger value="nodes" className="data-[state=active]:bg-slate-700">
节点 ({nodes.length})
{t('k8s.tab.nodes')} ({nodes.length})
</TabsTrigger>
<TabsTrigger value="pods" className="data-[state=active]:bg-slate-700">
Pod ({pods.length})
{t('k8s.tab.pods')} ({pods.length})
</TabsTrigger>
<TabsTrigger value="deployments" className="data-[state=active]:bg-slate-700">
Deployment ({deployments.length})
{t('k8s.tab.deployments')} ({deployments.length})
</TabsTrigger>
<TabsTrigger value="services" className="data-[state=active]:bg-slate-700">
Service ({services.length})
{t('k8s.tab.services')} ({services.length})
</TabsTrigger>
<TabsTrigger value="ingresses" className="data-[state=active]:bg-slate-700">
Ingress ({ingresses.length})
{t('k8s.tab.ingresses')} ({ingresses.length})
</TabsTrigger>
<TabsTrigger value="events" className="data-[state=active]:bg-slate-700">
事件
{t('k8s.tab.events')}
</TabsTrigger>
</TabsList>
@@ -151,13 +153,13 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
<Card className="bg-slate-800/50 border-slate-700">
<CardHeader className="pb-2">
<div className="flex items-center justify-between w-full">
<CardTitle className="text-sm text-slate-400">资源使用趋势(模拟最近变化)</CardTitle>
<CardTitle className="text-sm text-slate-400">{t('k8s.cluster.resourceTrend')}</CardTitle>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-slate-400 hover:text-slate-200"
onClick={() => cluster?.id && loadClusterDetail(cluster.id)}
title="刷新节点指标"
title={t('k8s.cluster.refreshMetrics')}
>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
@@ -197,7 +199,7 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
fill="url(#colorCpu)"
dot={false}
activeDot={{ r: 3, fill: '#3b82f6' }}
name="CPU(%)"
name={t('k8s.label.cpuPercent')}
/>
<Area
type="monotone"
@@ -207,39 +209,39 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
fill="url(#colorMemory)"
dot={false}
activeDot={{ r: 3, fill: '#10b981' }}
name="内存(%)"
name={t('k8s.label.memoryPercent')}
/>
</AreaChart>
</ResponsiveContainer>
</div>
<div className="flex justify-center gap-6 mt-2 text-xs text-slate-400">
<span>CPU: <span className="text-blue-400 font-bold">{clusterMetrics.cpuPct.toFixed(1)}%</span></span>
<span>内存: <span className="text-emerald-400 font-bold">{clusterMetrics.memPct.toFixed(1)}%</span></span>
<span>{t('k8s.label.cpu')}: <span className="text-blue-400 font-bold">{clusterMetrics.cpuPct.toFixed(1)}%</span></span>
<span>{t('k8s.label.memory')}: <span className="text-emerald-400 font-bold">{clusterMetrics.memPct.toFixed(1)}%</span></span>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800/50 border-slate-700">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-slate-400">资源分布</CardTitle>
<CardTitle className="text-sm text-slate-400">{t('k8s.cluster.resourceDistribution')}</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">{cluster.pod_count || 0}</div>
<div className="text-sm text-slate-500 mt-1">Pod</div>
<div className="text-sm text-slate-500 mt-1">{t('k8s.tab.pods')}</div>
</div>
<div className="text-center p-4 rounded-lg bg-slate-800">
<div className="text-3xl font-bold text-emerald-400">{cluster.service_count || 0}</div>
<div className="text-sm text-slate-500 mt-1">Service</div>
<div className="text-sm text-slate-500 mt-1">{t('k8s.tab.services')}</div>
</div>
<div className="text-center p-4 rounded-lg bg-slate-800">
<div className="text-3xl font-bold text-purple-400">{cluster.deployment_count || 0}</div>
<div className="text-sm text-slate-500 mt-1">Deployment</div>
<div className="text-sm text-slate-500 mt-1">{t('k8s.tab.deployments')}</div>
</div>
<div className="text-center p-4 rounded-lg bg-slate-800">
<div className="text-3xl font-bold text-amber-400">{cluster.node_count || 0}</div>
<div className="text-sm text-slate-500 mt-1">节点</div>
<div className="text-sm text-slate-500 mt-1">{t('k8s.tab.nodes')}</div>
</div>
</div>
</CardContent>
@@ -252,19 +254,19 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
<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>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.name')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.role')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('common.status')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.cpu')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.memory')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.tab.pods')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.os')}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{nodes.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">暂无节点数据</td>
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">{t('k8s.empty.nodes')}</td>
</tr>
) : (
nodes.map((node) => (
@@ -295,7 +297,7 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
</Badge>
</td>
<td className="px-4 py-3 text-sm text-slate-300">
{node.cpu_usage_m || '0m'} / {node.cpu}核
{node.cpu_usage_m || '0m'} / {node.cpu}{t('k8s.label.cores')}
</td>
<td className="px-4 py-3 text-sm text-slate-300">
{node.mem_usage_mi || '0Mi'} / {node.memory_mb ? (node.memory_mb / 1024).toFixed(0) + 'GB' : '-'}
@@ -315,19 +317,19 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
<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>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.name')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.namespace')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('common.status')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.restartCount')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.cpu')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.memory')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.node')}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{pods.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">暂无Pod数据</td>
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">{t('k8s.empty.pods')}</td>
</tr>
) : (
pods.map((pod) => (
@@ -369,18 +371,18 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
<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>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.name')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.namespace')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.replicas')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.availableReplicas')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.image')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.createdAt')}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{deployments.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-slate-500">暂无Deployment数据</td>
<td colSpan={6} className="px-4 py-8 text-center text-slate-500">{t('k8s.empty.deployments')}</td>
</tr>
) : (
deployments.map((deployment) => (
@@ -412,18 +414,18 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
<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">{t('k8s.column.name')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.namespace')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('common.type')}</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>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.ports')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.selector')}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{services.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-slate-500">暂无Service数据</td>
<td colSpan={6} className="px-4 py-8 text-center text-slate-500">{t('k8s.empty.services')}</td>
</tr>
) : (
services.map((service) => (
@@ -464,17 +466,17 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
<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>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.name')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.namespace')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.hosts')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.backend')}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">{t('k8s.column.tls')}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{ingresses.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-slate-500">暂无Ingress数据</td>
<td colSpan={5} className="px-4 py-8 text-center text-slate-500">{t('k8s.empty.ingresses')}</td>
</tr>
) : (
ingresses.map((ingress) => (
@@ -496,7 +498,7 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
: 'border-slate-700 text-slate-400'
}
>
{ingress.tls ? '已启用' : '未启用'}
{ingress.tls ? t('k8s.label.enabled') : t('k8s.label.disabled')}
</Badge>
</td>
</tr>
@@ -510,11 +512,11 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonDa
<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分钟前' },
{ type: 'Normal', reason: 'Started', message: 'Started container nginx', time: t('common.duration.minAgo', { count: 2 }) },
{ type: 'Normal', reason: 'Created', message: 'Created pod nginx-deployment-xxx', time: t('common.duration.minAgo', { count: 5 }) },
{ type: 'Warning', reason: 'FailedScheduling', message: '0/3 nodes are available', time: t('common.duration.minAgo', { count: 10 }) },
{ type: 'Normal', reason: 'Pulling', message: 'Pulling image "nginx:latest"', time: t('common.duration.minAgo', { count: 15 }) },
{ type: 'Normal', reason: 'Pulled', message: 'Successfully pulled image', time: t('common.duration.minAgo', { count: 12 }) },
].map((event, index) => (
<div
key={index}
@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useState, useEffect, useCallback, useRef } from 'react';
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -26,6 +27,7 @@ import {
} from './topology-helpers';
export function TopologyView() {
const { t } = useTranslation();
const { clusterCode } = useParams<{ clusterCode?: string }>();
const _confirm = useConfirm();
const [clusters, setClusters] = useState<any[]>([]);
@@ -172,9 +174,9 @@ export function TopologyView() {
if (connectMode) {
if (!connectSource) {
setConnectSource(node.id);
toast.info(`已选择源节点: ${node.name},请点击目标节点`);
toast.info(t('k8s.topology.sourceSelected', { name: node.name }));
} else if (connectSource === node.id) {
toast.info('不能连接自身');
toast.info(t('k8s.topology.connectSelf'));
} else {
// 创建自定义连线
const newEdge: TopologyEdge = {
@@ -182,7 +184,7 @@ export function TopologyView() {
source: connectSource, target: node.id, label: 'connect',
};
setTopology((prev) => ({ ...prev, edges: [...prev.edges, newEdge] }));
toast.success('连线已添加');
toast.success(t('k8s.topology.connectAdded'));
setConnectSource(null);
setConnectMode(false);
}
@@ -205,7 +207,7 @@ export function TopologyView() {
};
const handleSaveNode = () => {
if (!editForm.name.trim()) { toast.error('请输入节点名称'); return; }
if (!editForm.name.trim()) { toast.error(t('k8s.topology.nodeNameRequired')); return; }
if (editingNode) {
// 编辑现有节点
setTopology((prev) => ({
@@ -214,7 +216,7 @@ export function TopologyView() {
? { ...n, name: editForm.name, type: editForm.type, data: { ...n.data, note: editForm.note } }
: n),
}));
toast.success('节点已更新');
toast.success(t('k8s.topology.nodeUpdated'));
} else {
// 新增自定义节点
const id = `custom-${customIdCounter}`;
@@ -226,28 +228,28 @@ export function TopologyView() {
};
setTopology((prev) => ({ ...prev, nodes: [...prev.nodes, newNode] }));
setCustomIdCounter((c) => c + 1);
toast.success('节点已添加');
toast.success(t('k8s.topology.nodeAdded'));
}
setEditDialogOpen(false);
};
const handleDeleteNode = async (node: TopologyNode) => {
const ok = await _confirm({ title: '删除节点', description: `确定删除「${node.name}」?相关连线也会一并删除`, variant: 'destructive' });
const ok = await _confirm({ title: t('k8s.topology.deleteNodeTitle'), description: t('k8s.topology.deleteNodeDesc', { name: node.name }), variant: 'destructive' });
if (!ok) return;
setTopology((prev) => ({
nodes: prev.nodes.filter((n) => n.id !== node.id),
edges: prev.edges.filter((e) => e.source !== node.id && e.target !== node.id),
}));
setSelectedNode(null);
toast.success('节点已删除');
toast.success(t('k8s.topology.nodeDeleted'));
};
const handleDeleteEdge = async (edge: TopologyEdge) => {
if (!edge.id.startsWith('custom-edge')) { toast.info('Kubernetes 原生关系不可删除'); return; }
const ok = await _confirm({ title: '删除连线', description: '确定删除此自定义连线?', variant: 'destructive' });
if (!edge.id.startsWith('custom-edge')) { toast.info(t('k8s.topology.nativeEdgeNoDelete')); return; }
const ok = await _confirm({ title: t('k8s.topology.deleteEdgeTitle'), description: t('k8s.topology.deleteEdgeDesc'), variant: 'destructive' });
if (!ok) return;
setTopology((prev) => ({ ...prev, edges: prev.edges.filter((e) => e.id !== edge.id) }));
toast.success('连线已删除');
toast.success(t('k8s.topology.edgeDeleted'));
};
// --- 保存/重置布局 ---
@@ -261,13 +263,13 @@ export function TopologyView() {
customEdges: topology.edges.filter((e) => e.id.startsWith('custom-edge')),
};
await kubernetesApi.saveTopology(selectedClusterId, JSON.stringify(layoutData));
toast.success('布局已保存');
} catch { toast.error('保存失败'); }
toast.success(t('k8s.topology.layoutSaved'));
} catch { toast.error(t('k8s.topology.layoutSaveFailed')); }
finally { setSaving(false); }
};
const handleResetLayout = async () => {
const ok = await _confirm({ title: '重置布局', description: '将清除已保存的布局并重新自动排列', variant: 'destructive' });
const ok = await _confirm({ title: t('k8s.topology.resetLayout'), description: t('k8s.topology.resetConfirm'), variant: 'destructive' });
if (!ok) return;
try { await kubernetesApi.saveTopology(selectedClusterId, ''); } catch { /* */ }
setSavedLayout(null);
@@ -275,7 +277,7 @@ export function TopologyView() {
setConnectMode(false);
setConnectSource(null);
await loadTopology();
toast.success('布局已重置');
toast.success(t('k8s.topology.layoutReset'));
};
// --- 画布尺寸 ---
@@ -290,7 +292,7 @@ export function TopologyView() {
const renderTopology = () => {
if (topology.nodes.length === 0) return null;
const ys = [60, 180, 320, 480, 640];
const labels = ['集群', 'Ingress', 'Service', 'Deployment', 'Node'];
const labels = [t('k8s.tab.overview'), 'Ingress', 'Service', 'Deployment', 'Node'];
return (
<svg ref={svgRef} width="100%" height="100%" viewBox={`0 0 ${canvasW} ${canvasH}`} className="bg-slate-950 rounded-lg" preserveAspectRatio="xMidYMin meet"
onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp}>
@@ -319,7 +321,7 @@ export function TopologyView() {
markerEnd="url(#arrowhead)" opacity={isCustom ? 0.8 : 0.6} />
{editMode && isCustom && (
<text x={(source.x + target.x) / 2} y={(source.y + target.y) / 2}
fill="#f59e0b" fontSize="10" textAnchor="middle" className="select-none pointer-events-none">✕ 删除</text>
fill="#f59e0b" fontSize="10" textAnchor="middle" className="select-none pointer-events-none">✕ {t('k8s.topology.deleteText')}</text>
)}
</g>
);
@@ -372,23 +374,23 @@ export function TopologyView() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">架构拓扑</h1>
<h1 className="text-2xl font-bold text-slate-100">{t('k8s.topology.title')}</h1>
<p className="text-sm text-slate-500 mt-1">
Kubernetes 集群资源拓扑可视化 · 拖拽节点调整布局
{editMode && <span className="text-amber-400 ml-2">· 编辑模式</span>}
{connectMode && <span className="text-amber-400 ml-1">· 连线中{connectSource ? '(选择目标节点)' : '(选择源节点)'}</span>}
{t('k8s.topology.subtitle')}
{editMode && <span className="text-amber-400 ml-2">{`· ${t('k8s.topology.editModeActive')}`}</span>}
{connectMode && <span className="text-amber-400 ml-1">{`· ${t('k8s.topology.connecting')} ${connectSource ? t('k8s.topology.selectTarget') : t('k8s.topology.selectSource')}`}</span>}
</p>
</div>
<div className="flex items-center gap-2">
<Select value={selectedClusterId} onValueChange={(v) => { setSelectedClusterId(v); setSelectedNode(null); setEditMode(false); }}>
<SelectTrigger className="w-44 h-9 bg-slate-800/50 border-slate-700 text-slate-200"><SelectValue placeholder="选择集群" /></SelectTrigger>
<SelectTrigger className="w-44 h-9 bg-slate-800/50 border-slate-700 text-slate-200"><SelectValue placeholder={t('k8s.topology.selectCluster')} /></SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
{clusters.map((c) => <SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>)}
</SelectContent>
</Select>
<Button variant={editMode ? 'default' : 'outline'} size="sm" className="h-9"
onClick={() => { setEditMode(!editMode); setConnectMode(false); setConnectSource(null); }}>
<Pencil className="h-4 w-4 mr-1" />{editMode ? '退出编辑' : '编辑'}
<Pencil className="h-4 w-4 mr-1" />{editMode ? t('k8s.topology.exitEdit') : t('common.edit')}
</Button>
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400" onClick={loadTopology} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
@@ -402,9 +404,9 @@ export function TopologyView() {
await loadTopology();
}}
disabled={loading}
title="忽略保存的布局,重新根据 K8s 资源自动生成关联 (Ingress路径→Svc→Deploy→Pod)"
title={t('k8s.topology.autoGenerateTitle')}
>
<RotateCcw className="h-3.5 w-3.5 mr-1" /> 重新自动生成
<RotateCcw className="h-3.5 w-3.5 mr-1" /> {t('k8s.topology.autoGenerate')}
</Button>
</div>
</div>
@@ -413,31 +415,31 @@ export function TopologyView() {
{editMode && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-slate-800/50 border border-slate-700">
<Button size="sm" variant="outline" className="border-slate-600 text-slate-200" onClick={handleAddNode}>
<Plus className="h-4 w-4 mr-1" />添加节点
<Plus className="h-4 w-4 mr-1" />{t('k8s.topology.addNode')}
</Button>
<Button size="sm" variant={connectMode ? 'default' : 'outline'} className={connectMode ? 'bg-amber-600 hover:bg-amber-700' : 'border-slate-600 text-slate-200'}
onClick={() => { setConnectMode(!connectMode); setConnectSource(null); }}>
<Link2 className="h-4 w-4 mr-1" />{connectMode ? '取消连线' : '添加连线'}
<Link2 className="h-4 w-4 mr-1" />{connectMode ? t('k8s.topology.cancelConnect') : t('k8s.topology.addConnection')}
</Button>
<div className="h-5 w-px bg-slate-600" />
<Button size="sm" variant="outline" className="border-emerald-600/30 text-emerald-400 hover:bg-emerald-600/10" onClick={handleSaveLayout} disabled={saving}>
<Save className="h-4 w-4 mr-1" />{saving ? '保存中...' : '保存布局'}
<Save className="h-4 w-4 mr-1" />{saving ? t('k8s.topology.saving') : t('k8s.topology.saveLayout')}
</Button>
<Button size="sm" variant="outline" className="border-red-500/30 text-red-400 hover:bg-red-500/10" onClick={handleResetLayout}>
<RotateCcw className="h-4 w-4 mr-1" />重置布局
<RotateCcw className="h-4 w-4 mr-1" />{t('k8s.topology.resetLayout')}
</Button>
<span className="text-xs text-slate-500 ml-auto">双击节点编辑 · 点击虚线删除 · 拖拽调整位置</span>
<span className="text-xs text-slate-500 ml-auto">{t('k8s.topology.editToolbarHint')}</span>
</div>
)}
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{[
{ label: '总节点数', value: topology.nodes.length, icon: <Cloud className="h-5 w-5" />, color: 'text-blue-400', bg: 'bg-blue-500/10' },
{ label: '连接数', value: topology.edges.length, icon: <Link2 className="h-5 w-5" />, color: 'text-purple-400', bg: 'bg-purple-500/10' },
{ label: t('k8s.topology.totalNodes'), value: topology.nodes.length, icon: <Cloud className="h-5 w-5" />, color: 'text-blue-400', bg: 'bg-blue-500/10' },
{ label: t('k8s.topology.connections'), value: topology.edges.length, icon: <Link2 className="h-5 w-5" />, color: 'text-purple-400', bg: 'bg-purple-500/10' },
{ label: 'Service', value: countByType('service'), icon: getNodeIcon('service'), color: 'text-emerald-400', bg: 'bg-emerald-500/10' },
{ label: 'Deployment', value: countByType('app'), icon: getNodeIcon('app'), color: 'text-rose-400', bg: 'bg-rose-500/10' },
{ label: '自定义', value: topology.nodes.filter((n) => isCustomNode(n.id)).length, icon: getNodeIcon('custom'), color: 'text-amber-400', bg: 'bg-amber-500/10' },
{ label: t('k8s.topology.custom2'), value: topology.nodes.filter((n) => isCustomNode(n.id)).length, icon: getNodeIcon('custom'), color: 'text-amber-400', bg: 'bg-amber-500/10' },
].map((s) => (
<Card key={s.label} className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
@@ -457,12 +459,12 @@ export function TopologyView() {
<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" />
<Input placeholder={t('k8s.topology.searchPlaceholder')} value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200" />
</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>
<SelectTrigger className="w-36 h-9 bg-slate-800/50 border-slate-700 text-slate-200"><SelectValue placeholder={t('k8s.topology.nodeTypeLabel')} /></SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="all">全部类型</SelectItem>
<SelectItem value="all">{t('k8s.topology.allTypes')}</SelectItem>
{Object.entries(NODE_TYPE_LABELS).map(([v, l]) => <SelectItem key={v} value={v}>{l}</SelectItem>)}
</SelectContent>
</Select>
@@ -479,7 +481,7 @@ export function TopologyView() {
) : topology.nodes.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-slate-600">
<Cloud className="h-12 w-12 mb-3 opacity-50" />
<p className="text-sm">{selectedClusterId ? '该集群暂无拓扑数据' : '请选择集群查看拓扑'}</p>
<p className="text-sm">{selectedClusterId ? t('k8s.topology.noDataWithCluster') : t('k8s.topology.noDataSelectCluster')}</p>
</div>
) : renderTopology()}
</div>
@@ -489,20 +491,20 @@ export function TopologyView() {
<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>
<CardTitle className="text-base text-slate-100">{t('k8s.topology.nodeList')}</CardTitle>
<CardDescription className="text-slate-500">{t('k8s.topology.clickToViewDetail')}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2 max-h-[520px] overflow-auto">
{filteredNodes.length === 0 ? (
<p className="text-sm text-slate-600 text-center py-8">暂无节点</p>
<p className="text-sm text-slate-600 text-center py-8">{t('k8s.topology.noNodes')}</p>
) : (
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_LABELS[node.type] || node.type}{isCustomNode(node.id) ? ' · 自定义' : ''}</p>
<p className="text-xs text-slate-500">{NODE_TYPE_LABELS[node.type] || node.type}{isCustomNode(node.id) ? ` · ${t('k8s.topology.customSuffix')}` : ''}</p>
</div>
</div>
))
@@ -520,15 +522,15 @@ export function TopologyView() {
<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}
{selectedNode && isCustomNode(selectedNode.id) && <Badge variant="outline" className="border-amber-500/30 text-amber-400 text-xs">自定义</Badge>}
{selectedNode && isCustomNode(selectedNode.id) && <Badge variant="outline" className="border-amber-500/30 text-amber-400 text-xs">{t('k8s.topology.custom2')}</Badge>}
</DialogTitle>
<DialogDescription className="text-slate-500">类型: {NODE_TYPE_LABELS[selectedNode?.type || ''] || selectedNode?.type}</DialogDescription>
<DialogDescription className="text-slate-500">{t('common.type')}: {NODE_TYPE_LABELS[selectedNode?.type || ''] || selectedNode?.type}</DialogDescription>
</DialogHeader>
{selectedNode && (
<div className="space-y-4">
{renderNodeDetailFields(selectedNode)}
<div>
<h4 className="text-sm font-medium text-slate-300 mb-2">关联连接</h4>
<h4 className="text-sm font-medium text-slate-300 mb-2">{t('k8s.topology.relatedConnections')}</h4>
<div className="space-y-2">
{topology.edges.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id).map((edge) => {
const isSource = edge.source === selectedNode.id;
@@ -543,18 +545,18 @@ export function TopologyView() {
</div>
);
})}
{topology.edges.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id).length === 0 && <p className="text-xs text-slate-600">无关联连接</p>}
{topology.edges.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id).length === 0 && <p className="text-xs text-slate-600">{t('k8s.topology.noConnections')}</p>}
</div>
</div>
{/* 编辑模式操作按钮 */}
{editMode && selectedNode && (
<div className="flex gap-2 pt-2 border-t border-slate-700">
<Button size="sm" variant="outline" className="border-slate-600 text-slate-200" onClick={() => handleEditNode(selectedNode)}>
<Pencil className="h-3.5 w-3.5 mr-1" />编辑
<Pencil className="h-3.5 w-3.5 mr-1" />{t('common.edit')}
</Button>
{isCustomNode(selectedNode.id) && (
<Button size="sm" variant="outline" className="border-red-500/30 text-red-400 hover:bg-red-500/10" onClick={() => handleDeleteNode(selectedNode)}>
<Trash2 className="h-3.5 w-3.5 mr-1" />删除
<Trash2 className="h-3.5 w-3.5 mr-1" />{t('common.delete')}
</Button>
)}
</div>
@@ -568,17 +570,17 @@ export function TopologyView() {
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent className="bg-slate-900 border-slate-800 max-w-md">
<DialogHeader>
<DialogTitle className="text-slate-100">{editingNode ? '编辑节点' : '添加自定义节点'}</DialogTitle>
<DialogDescription className="text-slate-500">{editingNode ? '修改节点属性' : '添加数据库、外部服务等非 Kubernetes 资源节点'}</DialogDescription>
<DialogTitle className="text-slate-100">{editingNode ? t('k8s.topology.editNodeTitle') : t('k8s.topology.addNodeTitle')}</DialogTitle>
<DialogDescription className="text-slate-500">{editingNode ? t('k8s.topology.editNodeDesc') : t('k8s.topology.addNodeDesc')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-slate-300">名称</Label>
<Label className="text-slate-300">{t('k8s.topology.nodeName')}</Label>
<Input value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
placeholder="如 MySQL 主库、Redis 集群" className="bg-slate-800/50 border-slate-700 text-slate-200" />
placeholder={t('k8s.topology.namePlaceholder')} className="bg-slate-800/50 border-slate-700 text-slate-200" />
</div>
<div className="space-y-2">
<Label className="text-slate-300">类型</Label>
<Label className="text-slate-300">{t('k8s.topology.nodeType')}</Label>
<Select value={editForm.type} onValueChange={(v) => setEditForm({ ...editForm, type: v as TopologyNode['type'] })}>
<SelectTrigger className="bg-slate-800/50 border-slate-700 text-slate-200"><SelectValue /></SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
@@ -587,14 +589,14 @@ export function TopologyView() {
</Select>
</div>
<div className="space-y-2">
<Label className="text-slate-300">备注</Label>
<Label className="text-slate-300">{t('k8s.topology.nodeNote')}</Label>
<Textarea value={editForm.note} onChange={(e) => setEditForm({ ...editForm, note: e.target.value })}
placeholder="连接信息、用途说明等" rows={2} className="bg-slate-800/50 border-slate-700 text-slate-200 resize-none" />
placeholder={t('k8s.topology.notePlaceholder')} rows={2} className="bg-slate-800/50 border-slate-700 text-slate-200 resize-none" />
</div>
</div>
<DialogFooter>
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setEditDialogOpen(false)}>取消</Button>
<Button onClick={handleSaveNode}>{editingNode ? '保存' : '添加'}</Button>
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setEditDialogOpen(false)}>{t('common.cancel')}</Button>
<Button onClick={handleSaveNode}>{editingNode ? t('common.save') : t('k8s.topology.add2')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>