From d81dfe9efa833819b5ba6bba4a839d7aee6b4eba Mon Sep 17 00:00:00 2001 From: dengqichen Date: Sat, 13 Dec 2025 17:40:16 +0800 Subject: [PATCH] =?UTF-8?q?1.30=20k8s=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../K8s/List/components/DeploymentCard.tsx | 273 ------------ .../K8s/List/components/DeploymentRow.tsx | 151 +++++++ .../K8s/List/components/DeploymentTable.tsx | 58 +++ .../components/DeploymentTableSkeleton.tsx | 48 +++ .../K8s/List/components/FilterTabs.tsx | 38 ++ .../K8s/List/components/HealthBar.tsx | 38 ++ .../K8s/List/components/OperationMenu.tsx | 383 ++++++++++++++++ .../K8s/List/components/PodDetailDialog.tsx | 408 ------------------ .../K8s/List/components/PodListDialog.tsx | 196 --------- .../Resource/K8s/List/components/PodRow.tsx | 105 +++++ .../K8s/List/components/StatsCards.tsx | 92 ++++ .../Resource/K8s/List/hooks/useAutoRefresh.ts | 51 +++ .../K8s/List/hooks/useDeploymentExpand.ts | 40 ++ .../src/pages/Resource/K8s/List/index.tsx | 349 ++++++++------- .../src/pages/Resource/K8s/List/service.ts | 37 ++ frontend/src/pages/Resource/K8s/List/types.ts | 72 ++++ 16 files changed, 1297 insertions(+), 1042 deletions(-) delete mode 100644 frontend/src/pages/Resource/K8s/List/components/DeploymentCard.tsx create mode 100644 frontend/src/pages/Resource/K8s/List/components/DeploymentRow.tsx create mode 100644 frontend/src/pages/Resource/K8s/List/components/DeploymentTable.tsx create mode 100644 frontend/src/pages/Resource/K8s/List/components/DeploymentTableSkeleton.tsx create mode 100644 frontend/src/pages/Resource/K8s/List/components/FilterTabs.tsx create mode 100644 frontend/src/pages/Resource/K8s/List/components/HealthBar.tsx create mode 100644 frontend/src/pages/Resource/K8s/List/components/OperationMenu.tsx delete mode 100644 frontend/src/pages/Resource/K8s/List/components/PodDetailDialog.tsx delete mode 100644 frontend/src/pages/Resource/K8s/List/components/PodListDialog.tsx create mode 100644 frontend/src/pages/Resource/K8s/List/components/PodRow.tsx create mode 100644 frontend/src/pages/Resource/K8s/List/components/StatsCards.tsx create mode 100644 frontend/src/pages/Resource/K8s/List/hooks/useAutoRefresh.ts create mode 100644 frontend/src/pages/Resource/K8s/List/hooks/useDeploymentExpand.ts diff --git a/frontend/src/pages/Resource/K8s/List/components/DeploymentCard.tsx b/frontend/src/pages/Resource/K8s/List/components/DeploymentCard.tsx deleted file mode 100644 index d1c64aaa..00000000 --- a/frontend/src/pages/Resource/K8s/List/components/DeploymentCard.tsx +++ /dev/null @@ -1,273 +0,0 @@ -import React, { useState } from 'react'; -import { Card, CardContent } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { Package, Tag, FileCode, Copy, Check, Box } from 'lucide-react'; -import { useToast } from '@/components/ui/use-toast'; -import { YamlViewerDialog } from './YamlViewerDialog'; -import { PodListDialog } from './PodListDialog'; -import type { K8sDeploymentResponse } from '../types'; -import dayjs from 'dayjs'; - -interface DeploymentCardProps { - deployment: K8sDeploymentResponse; -} - -export const DeploymentCard: React.FC = ({ deployment }) => { - const { toast } = useToast(); - const [labelDialogOpen, setLabelDialogOpen] = useState(false); - const [yamlDialogOpen, setYamlDialogOpen] = useState(false); - const [podListDialogOpen, setPodListDialogOpen] = useState(false); - const [copiedIndex, setCopiedIndex] = useState(null); - const [copiedAll, setCopiedAll] = useState(false); - const [copiedImage, setCopiedImage] = useState(false); - - const labelCount = deployment.labels ? Object.keys(deployment.labels).length : 0; - - const getReplicaStatus = () => { - const ready = deployment.readyReplicas ?? 0; - const desired = deployment.replicas ?? 0; - - if (desired === 0) { - return { variant: 'secondary' as const, text: '0/0' }; - } - - if (ready === desired) { - return { variant: 'default' as const, text: `${ready}/${desired}` }; - } else if (ready > 0) { - return { variant: 'secondary' as const, text: `${ready}/${desired}` }; - } else { - return { variant: 'destructive' as const, text: `${ready}/${desired}` }; - } - }; - - const status = getReplicaStatus(); - - const handleCopyImage = (e: React.MouseEvent) => { - e.stopPropagation(); - if (!deployment.image) return; - navigator.clipboard.writeText(deployment.image).then(() => { - setCopiedImage(true); - toast({ - title: '已复制', - description: '已复制镜像地址到剪贴板', - }); - setTimeout(() => setCopiedImage(false), 2000); - }); - }; - - const handleCopyLabel = (key: string, value: string, index: number) => { - const text = `${key}:${value}`; - navigator.clipboard.writeText(text).then(() => { - setCopiedIndex(index); - toast({ - title: '已复制', - description: `已复制标签到剪贴板`, - }); - setTimeout(() => setCopiedIndex(null), 2000); - }); - }; - - const handleCopyAll = () => { - if (!deployment.labels || Object.keys(deployment.labels).length === 0) return; - - const allLabelsText = Object.entries(deployment.labels) - .map(([key, value]) => `${key}:${value}`) - .join('\n'); - - navigator.clipboard.writeText(allLabelsText).then(() => { - setCopiedAll(true); - toast({ - title: '已复制', - description: `已复制全部${labelCount}个标签`, - }); - setTimeout(() => setCopiedAll(false), 2000); - }); - }; - - return ( - <> - - -
-
-
- -
-

- {deployment.deploymentName} -

-
- - {status.text} - -
- -
- {deployment.image && ( -
-
- 镜像: {deployment.image} -
- -
- )} - -
-
- {labelCount} - 个标签 - {labelCount > 0 && } -
-
- - {deployment.k8sUpdateTime && ( -
- 更新: {dayjs(deployment.k8sUpdateTime).format('YYYY-MM-DD HH:mm')} -
- )} -
- -
- - - -
-
-
- - {/* 标签Dialog */} - {labelDialogOpen && ( -
setLabelDialogOpen(false)}> -
e.stopPropagation()}> -
-

- - 标签列表 -

- -
-
- {deployment.labels && Object.keys(deployment.labels).length > 0 ? ( -
- {Object.entries(deployment.labels).map(([key, value], index) => ( -
-
- {key} - : - {value} -
- -
- ))} -
- ) : ( -
- 暂无标签 -
- )} -
-
-
- )} - - {/* YAML查看器 */} - - - {/* Pod列表 */} - - - ); -}; diff --git a/frontend/src/pages/Resource/K8s/List/components/DeploymentRow.tsx b/frontend/src/pages/Resource/K8s/List/components/DeploymentRow.tsx new file mode 100644 index 00000000..7228d637 --- /dev/null +++ b/frontend/src/pages/Resource/K8s/List/components/DeploymentRow.tsx @@ -0,0 +1,151 @@ +import React, { useState, useEffect } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { ChevronDown, ChevronRight, Loader2, Layers } from 'lucide-react'; +import { useToast } from '@/components/ui/use-toast'; +import { HealthBar } from './HealthBar'; +import { PodRow } from './PodRow'; +import { OperationMenu } from './OperationMenu'; +import type { K8sDeploymentResponse, K8sPodResponse } from '../types'; +import { getDeploymentHealth, HealthStatusLabels } from '../types'; +import { getK8sPodsByDeployment } from '../service'; +import { useAutoRefresh } from '../hooks/useAutoRefresh'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import 'dayjs/locale/zh-cn'; + +dayjs.extend(relativeTime); +dayjs.locale('zh-cn'); + +interface DeploymentRowProps { + deployment: K8sDeploymentResponse; + isExpanded: boolean; + onToggleExpand: () => void; + onViewLogs: (pod: K8sPodResponse) => void; + onRefresh?: () => void; +} + +export const DeploymentRow: React.FC = ({ + deployment, + isExpanded, + onToggleExpand, + onViewLogs, + onRefresh, +}) => { + const { toast } = useToast(); + const [pods, setPods] = useState([]); + const [loading, setLoading] = useState(false); + const health = getDeploymentHealth(deployment); + const healthLabel = HealthStatusLabels[health]; + + const loadPods = async () => { + if (!isExpanded) return; + + try { + setLoading(true); + const data = await getK8sPodsByDeployment(deployment.id); + setPods(data || []); + } catch (error) { + console.error('加载Pod失败:', error); + toast({ + title: '加载失败', + description: '加载Pod列表失败', + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }; + + // 展开时加载Pod + useEffect(() => { + if (isExpanded) { + loadPods(); + } + }, [isExpanded]); + + // 展开时启动10秒轮询 + useAutoRefresh(loadPods, 10000, isExpanded); + + const ready = deployment.readyReplicas ?? 0; + const desired = deployment.replicas ?? 0; + + return ( + <> + + +
+ + + {deployment.deploymentName} +
+ + + + + + + + + + {deployment.image || '-'} + + + + 0 + + + + {deployment.k8sUpdateTime ? dayjs(deployment.k8sUpdateTime).fromNow() : '-'} + + + + + + + {isExpanded && ( + <> + {loading ? ( + + + + + + ) : pods.length === 0 ? ( + + + 此Deployment下暂无Pod + + + ) : ( + pods.map(pod => ( + + )) + )} + + )} + + ); +}; diff --git a/frontend/src/pages/Resource/K8s/List/components/DeploymentTable.tsx b/frontend/src/pages/Resource/K8s/List/components/DeploymentTable.tsx new file mode 100644 index 00000000..ee7caa52 --- /dev/null +++ b/frontend/src/pages/Resource/K8s/List/components/DeploymentTable.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { DeploymentRow } from './DeploymentRow'; +import type { K8sDeploymentResponse, K8sPodResponse } from '../types'; + +interface DeploymentTableProps { + deployments: K8sDeploymentResponse[]; + expandedIds: Set; + onToggleExpand: (id: number) => void; + onViewLogs: (pod: K8sPodResponse) => void; + onRefresh?: () => void; +} + +export const DeploymentTable: React.FC = ({ + deployments, + expandedIds, + onToggleExpand, + onViewLogs, + onRefresh, +}) => { + if (deployments.length === 0) { + return ( +
+ 暂无Deployment +
+ ); + } + + return ( +
+ + + + 名称 + 健康条 + 副本 + 镜像 + 重启 + 更新时间 + 操作 + + + + {deployments.map(deployment => ( + onToggleExpand(deployment.id)} + onViewLogs={onViewLogs} + onRefresh={onRefresh} + /> + ))} + +
+
+ ); +}; diff --git a/frontend/src/pages/Resource/K8s/List/components/DeploymentTableSkeleton.tsx b/frontend/src/pages/Resource/K8s/List/components/DeploymentTableSkeleton.tsx new file mode 100644 index 00000000..2a064d2e --- /dev/null +++ b/frontend/src/pages/Resource/K8s/List/components/DeploymentTableSkeleton.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { Skeleton } from '@/components/ui/skeleton'; + +export const DeploymentTableSkeleton: React.FC = () => { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ {[1, 2, 3, 4, 5].map((i) => ( +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ ))} +
+
+ ); +}; diff --git a/frontend/src/pages/Resource/K8s/List/components/FilterTabs.tsx b/frontend/src/pages/Resource/K8s/List/components/FilterTabs.tsx new file mode 100644 index 00000000..e6f35320 --- /dev/null +++ b/frontend/src/pages/Resource/K8s/List/components/FilterTabs.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { HealthStatus } from '../types'; + +interface FilterTabsProps { + activeFilter: HealthStatus | 'all'; + onFilterChange: (filter: HealthStatus | 'all') => void; + counts: { + all: number; + healthy: number; + warning: number; + critical: number; + }; +} + +export const FilterTabs: React.FC = ({ activeFilter, onFilterChange, counts }) => { + const filters = [ + { key: 'all' as const, label: '全部', count: counts.all }, + { key: HealthStatus.HEALTHY, label: '健康', count: counts.healthy }, + { key: HealthStatus.WARNING, label: '警告', count: counts.warning }, + { key: HealthStatus.CRITICAL, label: '异常', count: counts.critical }, + ]; + + return ( +
+ {filters.map(filter => ( + + ))} +
+ ); +}; diff --git a/frontend/src/pages/Resource/K8s/List/components/HealthBar.tsx b/frontend/src/pages/Resource/K8s/List/components/HealthBar.tsx new file mode 100644 index 00000000..a89f0612 --- /dev/null +++ b/frontend/src/pages/Resource/K8s/List/components/HealthBar.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import type { K8sDeploymentResponse } from '../types'; +import { getDeploymentHealth, HealthStatus } from '../types'; + +interface HealthBarProps { + deployment: K8sDeploymentResponse; +} + +export const HealthBar: React.FC = ({ deployment }) => { + const ready = deployment.readyReplicas ?? 0; + const desired = deployment.replicas ?? 0; + const percentage = desired > 0 ? (ready / desired) * 100 : 100; + const health = getDeploymentHealth(deployment); + + const getBarColor = () => { + switch (health) { + case HealthStatus.HEALTHY: + return 'bg-green-500'; + case HealthStatus.WARNING: + return 'bg-yellow-500'; + case HealthStatus.CRITICAL: + return 'bg-red-500'; + default: + return 'bg-gray-500'; + } + }; + + return ( +
+
+
+
+
+ ); +}; diff --git a/frontend/src/pages/Resource/K8s/List/components/OperationMenu.tsx b/frontend/src/pages/Resource/K8s/List/components/OperationMenu.tsx new file mode 100644 index 00000000..bef06b81 --- /dev/null +++ b/frontend/src/pages/Resource/K8s/List/components/OperationMenu.tsx @@ -0,0 +1,383 @@ +import React, { useState } from 'react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogBody, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useToast } from '@/components/ui/use-toast'; +import { + MoreVertical, + RotateCw, + Maximize2, + FileCode, + Tag, + Trash2, + Loader2, +} from 'lucide-react'; +import type { K8sDeploymentResponse } from '../types'; +import { restartK8sDeployment, scaleK8sDeployment, deleteK8sDeployment } from '../service'; +import { YamlViewerDialog } from './YamlViewerDialog'; + +interface OperationMenuProps { + deployment: K8sDeploymentResponse; + onSuccess?: () => void; +} + +export const OperationMenu: React.FC = ({ deployment, onSuccess }) => { + const { toast } = useToast(); + const [open, setOpen] = useState(false); + const [scaleDialogOpen, setScaleDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [yamlDialogOpen, setYamlDialogOpen] = useState(false); + const [labelDialogOpen, setLabelDialogOpen] = useState(false); + const [replicas, setReplicas] = useState(deployment.replicas?.toString() || '1'); + const [loading, setLoading] = useState(false); + const [copiedIndex, setCopiedIndex] = useState(null); + const [copiedAll, setCopiedAll] = useState(false); + + // 重启Deployment + const handleRestart = async () => { + try { + setLoading(true); + await restartK8sDeployment(deployment.id); + toast({ + title: '重启成功', + description: `Deployment ${deployment.deploymentName} 已重启`, + }); + onSuccess?.(); + } catch (error: any) { + toast({ + title: '重启失败', + description: error.message || '重启Deployment失败', + variant: 'destructive', + }); + } finally { + setLoading(false); + setOpen(false); + } + }; + + // 扩缩容 + const handleScale = async () => { + const newReplicas = parseInt(replicas); + if (isNaN(newReplicas) || newReplicas < 0) { + toast({ + title: '参数错误', + description: '副本数必须是非负整数', + variant: 'destructive', + }); + return; + } + + try { + setLoading(true); + await scaleK8sDeployment(deployment.id, newReplicas); + toast({ + title: '扩缩容成功', + description: `Deployment ${deployment.deploymentName} 副本数已调整为 ${newReplicas}`, + }); + onSuccess?.(); + } catch (error: any) { + toast({ + title: '扩缩容失败', + description: error.message || '扩缩容失败', + variant: 'destructive', + }); + } finally { + setLoading(false); + setScaleDialogOpen(false); + } + }; + + // 删除Deployment + const handleDelete = async () => { + try { + setLoading(true); + await deleteK8sDeployment(deployment.id); + toast({ + title: '删除成功', + description: `Deployment ${deployment.deploymentName} 已删除`, + }); + onSuccess?.(); + } catch (error: any) { + toast({ + title: '删除失败', + description: error.message || '删除Deployment失败', + variant: 'destructive', + }); + } finally { + setLoading(false); + setDeleteDialogOpen(false); + } + }; + + // 复制标签 + const handleCopyLabel = (key: string, value: string, index: number) => { + const text = `${key}:${value}`; + navigator.clipboard.writeText(text).then(() => { + setCopiedIndex(index); + toast({ + title: '已复制', + description: '已复制标签到剪贴板', + }); + setTimeout(() => setCopiedIndex(null), 2000); + }); + }; + + // 复制所有标签 + const handleCopyAllLabels = () => { + if (!deployment.labels || Object.keys(deployment.labels).length === 0) return; + + const allLabelsText = Object.entries(deployment.labels) + .map(([key, value]) => `${key}:${value}`) + .join('\n'); + + navigator.clipboard.writeText(allLabelsText).then(() => { + setCopiedAll(true); + const labelCount = Object.keys(deployment.labels!).length; + toast({ + title: '已复制', + description: `已复制全部${labelCount}个标签`, + }); + setTimeout(() => setCopiedAll(false), 2000); + }); + }; + + return ( + <> + + + + + + + + 重启Deployment + + { + setScaleDialogOpen(true); + setOpen(false); + }} + > + + 扩缩容 + + { + setYamlDialogOpen(true); + setOpen(false); + }} + > + + 查看YAML + + { + setLabelDialogOpen(true); + setOpen(false); + }} + > + + 查看标签 + + + { + setDeleteDialogOpen(true); + setOpen(false); + }} + className="text-red-600 focus:text-red-600" + > + + 删除 + + + + + {/* 扩缩容对话框 */} + + + + 扩缩容 + + 调整 {deployment.deploymentName} 的副本数量 + + + +
+
+ +
+ + setReplicas(e.target.value)} + className="text-center" + /> + +
+
+

+ 当前副本数: {deployment.replicas || 0} +

+
+
+ + + + +
+
+ + {/* 删除确认对话框 */} + + + + 确认删除 + + 确定要删除 Deployment {deployment.deploymentName} 吗? + 此操作不可撤销。 + + + + + + + + + + {/* YAML查看器 */} + + + {/* 标签查看对话框 */} + + + +
+ + + 标签列表 + + +
+
+
+ {deployment.labels && Object.keys(deployment.labels).length > 0 ? ( +
+ {Object.entries(deployment.labels).map(([key, value], index) => ( +
+
+ + {key} + + : + {value} +
+ +
+ ))} +
+ ) : ( +
暂无标签
+ )} +
+
+
+ + ); +}; diff --git a/frontend/src/pages/Resource/K8s/List/components/PodDetailDialog.tsx b/frontend/src/pages/Resource/K8s/List/components/PodDetailDialog.tsx deleted file mode 100644 index 69633bc3..00000000 --- a/frontend/src/pages/Resource/K8s/List/components/PodDetailDialog.tsx +++ /dev/null @@ -1,408 +0,0 @@ -import React, { useState } from 'react'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogBody } from '@/components/ui/dialog'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { Box, Tag, FileCode, Copy, Check, Network, Server, Package } from 'lucide-react'; -import { useToast } from '@/components/ui/use-toast'; -import { YamlViewerDialog } from './YamlViewerDialog'; -import type { K8sPodResponse } from '../types'; -import { PodPhaseLabels, ContainerStateLabels } from '../types'; -import dayjs from 'dayjs'; - -interface PodDetailDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - pod: K8sPodResponse; - deploymentId: number; -} - -export const PodDetailDialog: React.FC = ({ - open, - onOpenChange, - pod, -}) => { - const { toast } = useToast(); - const [yamlDialogOpen, setYamlDialogOpen] = useState(false); - const [labelDialogOpen, setLabelDialogOpen] = useState(false); - const [copiedIndex, setCopiedIndex] = useState(null); - const [copiedAll, setCopiedAll] = useState(false); - const [copiedField, setCopiedField] = useState(null); - - const phaseLabel = PodPhaseLabels[pod.phase]; - - const handleCopy = (text: string, field: string) => { - navigator.clipboard.writeText(text).then(() => { - setCopiedField(field); - toast({ - title: '已复制', - description: `已复制${field}到剪贴板`, - }); - setTimeout(() => setCopiedField(null), 2000); - }); - }; - - const handleCopyLabel = (key: string, value: string, index: number) => { - const text = `${key}:${value}`; - navigator.clipboard.writeText(text).then(() => { - setCopiedIndex(index); - toast({ - title: '已复制', - description: `已复制标签到剪贴板`, - }); - setTimeout(() => setCopiedIndex(null), 2000); - }); - }; - - const handleCopyAll = () => { - if (!pod.labels || Object.keys(pod.labels).length === 0) return; - - const allLabelsText = Object.entries(pod.labels) - .map(([key, value]) => `${key}:${value}`) - .join('\n'); - - navigator.clipboard.writeText(allLabelsText).then(() => { - setCopiedAll(true); - const labelCount = Object.keys(pod.labels!).length; - toast({ - title: '已复制', - description: `已复制全部${labelCount}个标签`, - }); - setTimeout(() => setCopiedAll(false), 2000); - }); - }; - - return ( - <> - - - - - - Pod详情: {pod.name} - - - - - {/* 概览信息 */} -
-

- - 概览信息 -

-
-
- 状态: - - {phaseLabel.label} - -
-
- 就绪: - - {pod.ready ? '是' : '否'} - -
-
- 重启次数: - {pod.restartCount} -
- {pod.ownerKind && ( -
- 控制器: - {pod.ownerKind} -
- )} - {pod.ownerName && ( -
- 控制器名称: - {pod.ownerName} -
- )} -
-
- - {/* 网络信息 */} -
-

- - 网络信息 -

-
- {pod.podIP && ( -
- Pod IP: -
- {pod.podIP} - -
-
- )} - {pod.hostIP && ( -
- 宿主机IP: -
- {pod.hostIP} - -
-
- )} - {pod.nodeName && ( -
- - - 节点名称: - -
- {pod.nodeName} - -
-
- )} -
-
- - {/* 容器列表 */} -
-

容器列表 ({pod.containers.length})

-
- {pod.containers.map((container, index) => { - const stateLabel = ContainerStateLabels[container.state]; - return ( -
-
- {container.name} -
- - {container.ready ? '就绪' : '未就绪'} - - - {stateLabel.label} - -
-
-
-
- 镜像: -
- - {container.image} - - -
-
-
- 重启次数: - {container.restartCount} -
- {(container.cpuRequest || container.cpuLimit) && ( -
- CPU: - {container.cpuRequest || '-'} / {container.cpuLimit || '-'} -
- )} - {(container.memoryRequest || container.memoryLimit) && ( -
- 内存: - {container.memoryRequest || '-'} / {container.memoryLimit || '-'} -
- )} - {container.startedAt && ( -
- 启动时间: - {dayjs(container.startedAt).format('YYYY-MM-DD HH:mm:ss')} -
- )} -
-
- ); - })} -
-
- - {/* 时间信息 */} -
-

时间信息

-
-
- 创建时间: - {dayjs(pod.creationTimestamp).format('YYYY-MM-DD HH:mm:ss')} -
- {pod.startTime && ( -
- 启动时间: - {dayjs(pod.startTime).format('YYYY-MM-DD HH:mm:ss')} -
- )} -
-
- - {/* 标签和注解 */} - {(pod.labels && Object.keys(pod.labels).length > 0) && ( -
-
-

- - 标签 ({Object.keys(pod.labels).length}) -

- -
-
- {Object.entries(pod.labels).slice(0, 5).map(([key, value]) => ( - - {key}: {value} - - ))} - {Object.keys(pod.labels).length > 5 && ( - +{Object.keys(pod.labels).length - 5} 更多 - )} -
-
- )} - - {/* 操作按钮 */} -
- -
-
-
-
- - {/* 标签Dialog */} - - - -
- - - 标签列表 - - -
-
- - {pod.labels && Object.keys(pod.labels).length > 0 ? ( -
- {Object.entries(pod.labels).map(([key, value], index) => ( -
-
- {key} - : - {value} -
- -
- ))} -
- ) : ( -
- 暂无标签 -
- )} -
-
-
- - {/* YAML查看器 */} - - - ); -}; diff --git a/frontend/src/pages/Resource/K8s/List/components/PodListDialog.tsx b/frontend/src/pages/Resource/K8s/List/components/PodListDialog.tsx deleted file mode 100644 index 92dbdf3a..00000000 --- a/frontend/src/pages/Resource/K8s/List/components/PodListDialog.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogBody } from '@/components/ui/dialog'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { Loader2, Box, Copy, Check, Network, Server } from 'lucide-react'; -import { useToast } from '@/components/ui/use-toast'; -import { PodDetailDialog } from './PodDetailDialog'; -import { getK8sPodsByDeployment } from '../service'; -import type { K8sPodResponse } from '../types'; -import { PodPhaseLabels } from '../types'; -import dayjs from 'dayjs'; - -interface PodListDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - deploymentId: number; - deploymentName: string; -} - -export const PodListDialog: React.FC = ({ - open, - onOpenChange, - deploymentId, - deploymentName, -}) => { - const { toast } = useToast(); - const [pods, setPods] = useState([]); - const [loading, setLoading] = useState(false); - const [selectedPod, setSelectedPod] = useState(null); - const [podDetailOpen, setPodDetailOpen] = useState(false); - const [copiedField, setCopiedField] = useState(null); - - const loadPods = async () => { - setLoading(true); - try { - const data = await getK8sPodsByDeployment(deploymentId); - setPods(data || []); - } catch (error) { - console.error('加载Pod失败:', error); - toast({ - title: '加载失败', - description: '加载Pod列表失败', - variant: 'destructive', - }); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - if (open && deploymentId) { - loadPods(); - } - }, [open, deploymentId]); - - const handlePodClick = (pod: K8sPodResponse) => { - setSelectedPod(pod); - setPodDetailOpen(true); - }; - - const handleCopy = (text: string, field: string, e: React.MouseEvent) => { - e.stopPropagation(); - navigator.clipboard.writeText(text).then(() => { - setCopiedField(field); - toast({ - title: '已复制', - description: `已复制${field}到剪贴板`, - }); - setTimeout(() => setCopiedField(null), 2000); - }); - }; - - return ( - <> - - - - - - Deployment: {deploymentName} 的 Pod - - - - - {loading ? ( -
- -
- ) : pods.length === 0 ? ( -
- 此Deployment下暂无Pod -
- ) : ( -
- {pods.map((pod) => { - const phaseLabel = PodPhaseLabels[pod.phase]; - return ( -
handlePodClick(pod)} - > -
-

- {pod.name} -

- - {phaseLabel.label} - -
- -
-
- 就绪状态: - - {pod.ready ? '就绪' : '未就绪'} - -
- - {pod.restartCount > 0 && ( -
- 重启次数: - - {pod.restartCount} - -
- )} - - {pod.nodeName && ( -
- - {pod.nodeName} - -
- )} - - {pod.podIP && ( -
- - {pod.podIP} - -
- )} - -
- 容器: {pod.containers.length} 个 -
- - {pod.creationTimestamp && ( -
- 创建: {dayjs(pod.creationTimestamp).format('MM-DD HH:mm')} -
- )} -
-
- ); - })} -
- )} -
-
-
- - {selectedPod && ( - - )} - - ); -}; diff --git a/frontend/src/pages/Resource/K8s/List/components/PodRow.tsx b/frontend/src/pages/Resource/K8s/List/components/PodRow.tsx new file mode 100644 index 00000000..1f13bb5f --- /dev/null +++ b/frontend/src/pages/Resource/K8s/List/components/PodRow.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { FileText, Copy, Check, Box } from 'lucide-react'; +import { useToast } from '@/components/ui/use-toast'; +import type { K8sPodResponse } from '../types'; +import { PodPhaseLabels } from '../types'; + +interface PodRowProps { + pod: K8sPodResponse; + onViewLogs: (pod: K8sPodResponse) => void; +} + +export const PodRow: React.FC = ({ pod, onViewLogs }) => { + const { toast } = useToast(); + const [copiedField, setCopiedField] = React.useState(null); + const phaseLabel = PodPhaseLabels[pod.phase]; + + const handleCopy = (text: string, field: string) => { + navigator.clipboard.writeText(text).then(() => { + setCopiedField(field); + toast({ + title: '已复制', + description: `已复制${field}到剪贴板`, + }); + setTimeout(() => setCopiedField(null), 2000); + }); + }; + + return ( + + +
+ {/* Pod名称 */} +
+ + {pod.name} +
+ + {/* 状态 */} +
+ + {phaseLabel.label} + +
+ + {/* 就绪状态 */} +
+ + {pod.ready ? '就绪' : '未就绪'} + +
+ + {/* 重启次数 */} +
+ {pod.restartCount > 0 ? ( + + {pod.restartCount}次 + + ) : ( + - + )} +
+ + {/* Pod IP */} +
+ {pod.podIP || '-'} + {pod.podIP && ( + + )} +
+ + {/* 节点 */} +
+ {pod.nodeName || '-'} +
+ + {/* 日志按钮 */} +
+ +
+
+ + + ); +}; diff --git a/frontend/src/pages/Resource/K8s/List/components/StatsCards.tsx b/frontend/src/pages/Resource/K8s/List/components/StatsCards.tsx new file mode 100644 index 00000000..cc043da5 --- /dev/null +++ b/frontend/src/pages/Resource/K8s/List/components/StatsCards.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Package, CheckCircle, AlertTriangle, XCircle, RefreshCw } from 'lucide-react'; +import type { K8sDeploymentResponse } from '../types'; +import { getDeploymentHealth, HealthStatus } from '../types'; + +interface StatsCardsProps { + deployments: K8sDeploymentResponse[]; +} + +export const StatsCards: React.FC = ({ deployments }) => { + const stats = React.useMemo(() => { + const total = deployments.length; + let healthy = 0; + let warning = 0; + let critical = 0; + let totalRestarts = 0; + + deployments.forEach(deployment => { + const health = getDeploymentHealth(deployment); + if (health === HealthStatus.HEALTHY) healthy++; + else if (health === HealthStatus.WARNING) warning++; + else if (health === HealthStatus.CRITICAL) critical++; + }); + + return { total, healthy, warning, critical, totalRestarts }; + }, [deployments]); + + return ( +
+ + +
+ +
+

总数

+

{stats.total}

+
+
+
+
+ + + +
+ +
+

健康

+

{stats.healthy}

+
+
+
+
+ + + +
+ +
+

警告

+

{stats.warning}

+
+
+
+
+ + + +
+ +
+

异常

+

{stats.critical}

+
+
+
+
+ + + +
+ +
+

重启次数

+

{stats.totalRestarts}

+
+
+
+
+
+ ); +}; diff --git a/frontend/src/pages/Resource/K8s/List/hooks/useAutoRefresh.ts b/frontend/src/pages/Resource/K8s/List/hooks/useAutoRefresh.ts new file mode 100644 index 00000000..a0483181 --- /dev/null +++ b/frontend/src/pages/Resource/K8s/List/hooks/useAutoRefresh.ts @@ -0,0 +1,51 @@ +import { useEffect, useRef, useCallback } from 'react'; + +/** + * 自动刷新Hook + * @param callback 刷新回调函数 + * @param interval 刷新间隔(毫秒) + * @param enabled 是否启用自动刷新 + */ +export const useAutoRefresh = ( + callback: () => void | Promise, + interval: number, + enabled: boolean = true +) => { + const savedCallback = useRef(callback); + const timerRef = useRef(); + + // 更新回调引用 + useEffect(() => { + savedCallback.current = callback; + }, [callback]); + + // 启动/停止定时器 + useEffect(() => { + if (!enabled) { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = undefined; + } + return; + } + + const tick = () => { + savedCallback.current(); + }; + + timerRef.current = setInterval(tick, interval); + + return () => { + if (timerRef.current) { + clearInterval(timerRef.current); + } + }; + }, [interval, enabled]); + + // 手动触发刷新 + const refresh = useCallback(() => { + savedCallback.current(); + }, []); + + return { refresh }; +}; diff --git a/frontend/src/pages/Resource/K8s/List/hooks/useDeploymentExpand.ts b/frontend/src/pages/Resource/K8s/List/hooks/useDeploymentExpand.ts new file mode 100644 index 00000000..a78d8eaf --- /dev/null +++ b/frontend/src/pages/Resource/K8s/List/hooks/useDeploymentExpand.ts @@ -0,0 +1,40 @@ +import { useState, useCallback } from 'react'; + +/** + * Deployment展开状态管理Hook + */ +export const useDeploymentExpand = () => { + const [expandedIds, setExpandedIds] = useState>(new Set()); + + const toggleExpand = useCallback((id: number) => { + setExpandedIds(prev => { + const newSet = new Set(prev); + if (newSet.has(id)) { + newSet.delete(id); + } else { + newSet.add(id); + } + return newSet; + }); + }, []); + + const isExpanded = useCallback((id: number) => { + return expandedIds.has(id); + }, [expandedIds]); + + const expandAll = useCallback((ids: number[]) => { + setExpandedIds(new Set(ids)); + }, []); + + const collapseAll = useCallback(() => { + setExpandedIds(new Set()); + }, []); + + return { + expandedIds, + toggleExpand, + isExpanded, + expandAll, + collapseAll, + }; +}; diff --git a/frontend/src/pages/Resource/K8s/List/index.tsx b/frontend/src/pages/Resource/K8s/List/index.tsx index 32fc236c..76bae2e0 100644 --- a/frontend/src/pages/Resource/K8s/List/index.tsx +++ b/frontend/src/pages/Resource/K8s/List/index.tsx @@ -1,17 +1,25 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { PageContainer } from '@/components/ui/page-container'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { RefreshCw, Loader2, Cloud, History as HistoryIcon, Database } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { RefreshCw, Loader2, Cloud, History as HistoryIcon, Database, Search } from 'lucide-react'; import { useToast } from '@/components/ui/use-toast'; -import { DeploymentCard } from './components/DeploymentCard'; +import { StatsCards } from './components/StatsCards'; +import { FilterTabs } from './components/FilterTabs'; +import { DeploymentTable } from './components/DeploymentTable'; +import { DeploymentTableSkeleton } from './components/DeploymentTableSkeleton'; import { SyncHistoryTab } from './components/SyncHistoryTab'; import { getExternalSystemList } from '@/pages/Resource/External/List/service'; import { syncK8sNamespace, syncK8sDeployment, getK8sNamespaceList, getK8sDeploymentByNamespace } from './service'; import type { ExternalSystemResponse } from '@/pages/Resource/External/List/types'; -import type { K8sNamespaceResponse, K8sDeploymentResponse } from './types'; +import type { K8sNamespaceResponse, K8sDeploymentResponse, K8sPodResponse } from './types'; import { SystemType } from '@/pages/Resource/External/List/types'; +import { getDeploymentHealth, HealthStatus } from './types'; +import { useAutoRefresh } from './hooks/useAutoRefresh'; +import { useDeploymentExpand } from './hooks/useDeploymentExpand'; +import dayjs from 'dayjs'; const K8sManagement: React.FC = () => { const { toast } = useToast(); @@ -22,14 +30,17 @@ const K8sManagement: React.FC = () => { const [deployments, setDeployments] = useState([]); const [syncingNamespace, setSyncingNamespace] = useState(false); const [syncingDeployment, setSyncingDeployment] = useState(false); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); + const [initialLoading, setInitialLoading] = useState(true); const [showHistory, setShowHistory] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [activeFilter, setActiveFilter] = useState('all'); + const [lastUpdateTime, setLastUpdateTime] = useState(new Date()); + + const { expandedIds, toggleExpand } = useDeploymentExpand(); // 加载K8S集群列表 const loadK8sClusters = async () => { try { - setLoading(true); const clusters = await getExternalSystemList({ type: SystemType.K8S }); // 过滤出启用的集群 const enabledClusters = (clusters || []).filter(c => c.enabled); @@ -47,7 +58,7 @@ const K8sManagement: React.FC = () => { variant: 'destructive', }); } finally { - setLoading(false); + setInitialLoading(false); } }; @@ -79,6 +90,7 @@ const K8sManagement: React.FC = () => { try { const data = await getK8sDeploymentByNamespace(selectedClusterId, selectedNamespaceId); setDeployments(data || []); + setLastUpdateTime(new Date()); } catch (error) { console.error('加载Deployment失败:', error); toast({ @@ -89,6 +101,9 @@ const K8sManagement: React.FC = () => { } }; + // 30秒自动刷新 + useAutoRefresh(loadDeployments, 30000, !showHistory && !!selectedNamespaceId); + useEffect(() => { loadK8sClusters(); }, []); @@ -163,34 +178,84 @@ const K8sManagement: React.FC = () => { } }; - // 刷新页面数据 + // 手动刷新 const handleRefresh = async () => { - if (!selectedClusterId || !selectedNamespaceId) return; - - try { - setRefreshing(true); - await loadDeployments(); - toast({ - title: '刷新成功', - description: 'Deployment列表已更新', - }); - } catch (error) { - toast({ - title: '刷新失败', - description: '刷新Deployment列表失败', - variant: 'destructive', - }); - } finally { - setRefreshing(false); - } + await loadDeployments(); + toast({ + title: '刷新成功', + description: 'Deployment列表已更新', + }); }; - if (loading) { + // 筛选和搜索 + const filteredDeployments = useMemo(() => { + let filtered = deployments; + + // 按健康状态筛选 + if (activeFilter !== 'all') { + filtered = filtered.filter(d => getDeploymentHealth(d) === activeFilter); + } + + // 按搜索关键词筛选 + if (searchQuery) { + const query = searchQuery.toLowerCase(); + filtered = filtered.filter(d => + d.deploymentName.toLowerCase().includes(query) || + d.image?.toLowerCase().includes(query) + ); + } + + // 排序:异常 -> 警告 -> 健康 + return filtered.sort((a, b) => { + const healthOrder = { critical: 0, warning: 1, healthy: 2 }; + const healthA = getDeploymentHealth(a); + const healthB = getDeploymentHealth(b); + return healthOrder[healthA] - healthOrder[healthB]; + }); + }, [deployments, activeFilter, searchQuery]); + + // 统计数据 + const stats = useMemo(() => { + const all = deployments.length; + let healthy = 0; + let warning = 0; + let critical = 0; + + deployments.forEach(d => { + const health = getDeploymentHealth(d); + if (health === HealthStatus.HEALTHY) healthy++; + else if (health === HealthStatus.WARNING) warning++; + else if (health === HealthStatus.CRITICAL) critical++; + }); + + return { all, healthy, warning, critical }; + }, [deployments]); + + // 处理日志查看 + const handleViewLogs = (pod: K8sPodResponse) => { + // TODO: 打开日志抽屉 + console.log('查看日志:', pod.name); + }; + + if (initialLoading) { return ( -
- +
+

K8S管理

+

+ 管理Kubernetes集群资源,同步命名空间和Deployment +

+ + +
+
+
+
+
+ + + ); } @@ -221,127 +286,82 @@ const K8sManagement: React.FC = () => {

+ {/* 顶部控制栏 */} - - - - K8S集群 - - - +
-
- -
+ -
- setSelectedNamespaceId(Number(value))} + disabled={!selectedClusterId || namespaces.length === 0} + > + + + + + {namespaces.map((namespace) => ( + +
+ + {namespace.namespaceName}
- ) : ( - 选择命名空间 - )} - - - {namespaces.map((namespace) => ( - -
- - {namespace.namespaceName} - {namespace.status && ( - - {namespace.status} - - )} -
-
- ))} -
- -
- - - - + + ))} + + - - + + + +
+ + {!showHistory && selectedNamespaceId && ( +
+ 最后更新: {dayjs(lastUpdateTime).format('HH:mm:ss')} ({dayjs(lastUpdateTime).fromNow()}) +
+ )}
@@ -354,32 +374,31 @@ const K8sManagement: React.FC = () => { + ) : !selectedNamespaceId ? ( +
+ 请先选择集群和命名空间 +
) : ( - - - Deployment - - - {!selectedNamespaceId ? ( -
- 请先选择命名空间 -
- ) : deployments.length === 0 ? ( -
- 此命名空间下暂无Deployment -
- ) : ( -
- {deployments.map((deployment) => ( - - ))} -
- )} -
-
+ <> + {/* 统计卡片 */} + + + {/* 筛选标签 */} + + + {/* Deployment表格 */} + + )} ); diff --git a/frontend/src/pages/Resource/K8s/List/service.ts b/frontend/src/pages/Resource/K8s/List/service.ts index c7f1740e..74d30da1 100644 --- a/frontend/src/pages/Resource/K8s/List/service.ts +++ b/frontend/src/pages/Resource/K8s/List/service.ts @@ -164,3 +164,40 @@ export const getK8sPodDetail = async ( ): Promise => { return request.get(`/api/v1/k8s-deployment/${deploymentId}/pods/${podName}`); }; + +// ==================== K8S日志接口 ==================== + +/** + * 获取Pod日志 + */ +export const getK8sPodLogs = async ( + deploymentId: number, + podName: string, + params?: { + container?: string; + tail?: number; + since?: string; + follow?: boolean; + } +): Promise => { + return request.get(`/api/v1/k8s-deployment/${deploymentId}/pods/${podName}/logs`, { params }); +}; + +// ==================== K8S操作接口 ==================== + +/** + * 重启Deployment + */ +export const restartK8sDeployment = async (id: number): Promise<{ success: boolean; message: string }> => { + return request.post(`/api/v1/k8s-deployment/${id}/restart`); +}; + +/** + * 扩缩容Deployment + */ +export const scaleK8sDeployment = async ( + id: number, + replicas: number +): Promise<{ success: boolean; message: string }> => { + return request.post(`/api/v1/k8s-deployment/${id}/scale`, { replicas }); +}; diff --git a/frontend/src/pages/Resource/K8s/List/types.ts b/frontend/src/pages/Resource/K8s/List/types.ts index 0aba6b99..b97c9d2c 100644 --- a/frontend/src/pages/Resource/K8s/List/types.ts +++ b/frontend/src/pages/Resource/K8s/List/types.ts @@ -277,3 +277,75 @@ export interface K8sPodResponse { /** YAML配置 */ yamlConfig?: string; } + +// ==================== 日志相关 ==================== + +/** + * Pod日志查询参数 + */ +export interface PodLogsQuery { + /** 容器名称 */ + container?: string; + /** 显示最后N行 */ + tail?: number; + /** 时间戳,显示此时间之后的日志 */ + since?: string; + /** 是否实时跟踪 */ + follow?: boolean; +} + +// ==================== 操作相关 ==================== + +/** + * 扩缩容请求 + */ +export interface ScaleRequest { + /** 副本数 */ + replicas: number; +} + +/** + * 操作响应 + */ +export interface OperationResponse { + /** 是否成功 */ + success: boolean; + /** 消息 */ + message: string; +} + +// ==================== 健康状态 ==================== + +/** + * 健康状态枚举 + */ +export enum HealthStatus { + /** 健康 */ + HEALTHY = 'healthy', + /** 警告 */ + WARNING = 'warning', + /** 异常 */ + CRITICAL = 'critical', +} + +/** + * 计算Deployment健康状态 + */ +export const getDeploymentHealth = (deployment: K8sDeploymentResponse): HealthStatus => { + const ready = deployment.readyReplicas ?? 0; + const desired = deployment.replicas ?? 0; + + if (desired === 0) return HealthStatus.HEALTHY; + if (ready === 0) return HealthStatus.CRITICAL; + if (ready < desired) return HealthStatus.WARNING; + return HealthStatus.HEALTHY; +}; + +/** + * 健康状态标签映射 + */ +export const HealthStatusLabels: Record = { + [HealthStatus.HEALTHY]: { label: '健康', color: 'text-green-600', bgColor: 'bg-green-100' }, + [HealthStatus.WARNING]: { label: '警告', color: 'text-yellow-600', bgColor: 'bg-yellow-100' }, + [HealthStatus.CRITICAL]: { label: '异常', color: 'text-red-600', bgColor: 'bg-red-100' }, +};