From 1a913549fcfbf510c65b87954bea77db2b29e007 Mon Sep 17 00:00:00 2001 From: attacker Date: Fri, 19 Jun 2026 07:36:13 +0000 Subject: [PATCH] =?UTF-8?q?feat(k8s-ui):=20=E9=87=8D=E5=86=99=20K8s=20?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=20=E2=80=94=20Deployment=20=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E9=A1=B5/HPA/CronJob/=E6=93=8D=E4=BD=9C=E6=8C=89?= =?UTF-8?q?=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api.ts 改造: - 修复 scaleDeployment/restartDeployment/deletePod 死桩 → 真实后端 API - 新增: getPodLogs/execPod/updateDeploymentImage (CI webhook) - 新增: getHPAs/createHPA/deleteHPA (HPA 管理) - 新增: getCronJobs/createCronJob/suspendCronJob/triggerCronJob/deleteCronJob - 新增: getNamespaces DeploymentDetail 新组件: - 副本状态卡片 (Ready/Total/Available) - 副本加减按钮 (scale control) - 滚动重启按钮 - 镜像更新表单 (CI webhook inline) - Pod 列表 (按 deployment 名前缀过滤) - Pod 日志查看器 (tail/container 参数) - HPA 管理选项卡 (列表+删除) - 创建 HPA 弹窗 (CPU/内存指标配置) K8sWorkloads 增强: - Deployment 表添加「操作」列(副本加减 + 查看详情) - 点击 Deployment 行 → 跳转 DeploymentDetail 详情页 - handleQuickScale 快速扩缩容 新增 useToast hook (轻量级 toast 通知) --- .../src/components/k8s/DeploymentDetail.tsx | 570 ++++++++++++++++++ frontend/src/components/k8s/K8sWorkloads.tsx | 63 +- frontend/src/hooks/use-toast.tsx | 33 + frontend/src/services/api.ts | 58 +- 4 files changed, 714 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/k8s/DeploymentDetail.tsx create mode 100644 frontend/src/hooks/use-toast.tsx diff --git a/frontend/src/components/k8s/DeploymentDetail.tsx b/frontend/src/components/k8s/DeploymentDetail.tsx new file mode 100644 index 0000000..130ca4a --- /dev/null +++ b/frontend/src/components/k8s/DeploymentDetail.tsx @@ -0,0 +1,570 @@ +import { useState, useEffect, useCallback } 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from '@/components/ui/table'; +import { + ArrowLeft, Minus, Plus, RotateCw, Upload, FileText, Terminal, + Gauge, Clock, Play, Pause, Trash2, RefreshCw, Loader2, + CheckCircle, AlertTriangle, XCircle, ExternalLink, +} from 'lucide-react'; +import { k8sApi } from '@/services/api'; +import { useToast } from '@/hooks/use-toast'; + +interface DeploymentDetailProps { + clusterId: string; + namespace: string; + deploymentName: string; + onBack: () => void; +} + +export function DeploymentDetail({ clusterId, namespace, deploymentName, onBack }: DeploymentDetailProps) { + const { toast } = useToast(); + const [deployment, setDeployment] = useState(null); + const [pods, setPods] = useState([]); + const [hpas, setHpas] = useState([]); + const [logs, setLogs] = useState(''); + const [loading, setLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(''); + + // Scale + const [scaleValue, setScaleValue] = useState(0); + // Image update + const [newImage, setNewImage] = useState(''); + const [showImageUpdate, setShowImageUpdate] = useState(false); + // Logs + const [logPod, setLogPod] = useState(''); + const [showLogs, setShowLogs] = useState(false); + const [logTail, setLogTail] = useState(200); + + const loadData = useCallback(async () => { + setLoading(true); + try { + const [depRes, podRes, hpaRes] = await Promise.all([ + k8sApi.getDeployments(clusterId).catch(() => []), + k8sApi.getPods(clusterId).catch(() => []), + k8sApi.getHPAs(clusterId).catch(() => ({ items: [] })), + ]); + const toArray = (v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || [])); + const allDeps = toArray(depRes); + const allPods = toArray(podRes); + const allHpas = toArray(hpaRes); + + const dep = allDeps.find((d: any) => d.name === deploymentName && d.namespace === namespace); + setDeployment(dep || null); + if (dep) { + setScaleValue(dep.replicas || 0); + setNewImage(dep.image || ''); + } + + // Filter pods by deployment label (prefix match on pod name) + const depPods = allPods.filter((p: any) => + p.namespace === namespace && p.name.startsWith(deploymentName) + ); + setPods(depPods); + + // Filter HPAs targeting this deployment + const depHpas = allHpas.filter((h: any) => { + const ref = h.spec?.scaleTargetRef; + return ref?.name === deploymentName && h.metadata?.namespace === namespace; + }); + setHpas(depHpas); + } catch (e) { + console.error('加载详情失败:', e); + } finally { + setLoading(false); + } + }, [clusterId, namespace, deploymentName]); + + useEffect(() => { loadData(); }, [loadData]); + + const handleScale = async (delta: number) => { + const newReplicas = (deployment?.replicas || 0) + delta; + if (newReplicas < 0) return; + setActionLoading('scale'); + try { + await k8sApi.scaleDeployment(clusterId, namespace, deploymentName, newReplicas); + toast({ title: '扩缩容成功', description: `副本数已设置为 ${newReplicas}` }); + loadData(); + } catch (e: any) { + toast({ title: '操作失败', description: e?.message || String(e), variant: 'destructive' }); + } finally { setActionLoading(''); } + }; + + const handleRestart = async () => { + setActionLoading('restart'); + try { + await k8sApi.restartDeployment(clusterId, namespace, deploymentName); + toast({ title: '重启指令已下发', description: 'Deployment 正在滚动重启' }); + } catch (e: any) { + toast({ title: '重启失败', description: e?.message || String(e), variant: 'destructive' }); + } finally { setActionLoading(''); } + }; + + const handleUpdateImage = async () => { + if (!newImage.trim()) return; + setActionLoading('image'); + try { + await k8sApi.updateDeploymentImage(clusterId, namespace, deploymentName, newImage); + toast({ title: '镜像更新成功', description: `正在滚动更新为 ${newImage}` }); + setShowImageUpdate(false); + loadData(); + } catch (e: any) { + toast({ title: '更新失败', description: e?.message || String(e), variant: 'destructive' }); + } finally { setActionLoading(''); } + }; + + const handleLoadLogs = async (podName: string) => { + setLogPod(podName); + setShowLogs(true); + setLogs(''); + try { + const res: any = await k8sApi.getPodLogs(clusterId, namespace, podName, logTail); + setLogs(res?.data?.logs || res?.logs || '无日志'); + } catch { + setLogs('获取日志失败'); + } + }; + + const handleHpaToggle = async (hpa: any) => { + // Not direct toggle — delete HPA + if (!confirm(`确定删除 HPA "${hpa.metadata?.name}"?`)) return; + try { + await k8sApi.deleteHPA(clusterId, namespace, hpa.metadata?.name); + toast({ title: 'HPA 已删除' }); + loadData(); + } catch (e: any) { + toast({ title: '删除失败', description: e?.message || String(e), variant: 'destructive' }); + } + }; + + if (loading) { + return ( + + + +

加载中...

+
+
+ ); + } + + if (!deployment) { + return ( + + + +

未找到 Deployment: {deploymentName}

+ +
+
+ ); + } + + const ready = deployment.ready_replicas || deployment.readyReplicas || 0; + const total = deployment.replicas || 0; + const available = deployment.available_replicas || deployment.availableReplicas || 0; + const isHealthy = ready === total; + + return ( +
+ {/* Header */} +
+
+ +
+
+

{deploymentName}

+ {namespace} +
+

镜像: {deployment.image || '-'}

+
+
+ +
+ + {/* Status Bar */} +
+ + +

副本状态

+

+ {ready}/{total} +

+
+
+ + +

可用副本

+

{available}

+
+
+ + +

镜像

+

+ {deployment.image?.split('/').pop() || '-'} +

+
+
+ + +

HPA

+

{hpas.length > 0 ? '已启用' : '未配置'}

+
+
+
+ + {/* Actions */} + + + 操作 + + +
+ {/* Scale controls */} +
+ + + {actionLoading === 'scale' ? : total} + + +
+ + + + +
+ + {/* Inline image update */} + {showImageUpdate && ( +
+ setNewImage(e.target.value)} + placeholder="输入新镜像地址,如 harbor.example.com/app:v2.0" + className="flex-1 h-9 bg-slate-900 border-slate-700 text-slate-200 text-sm font-mono" + /> + + +
+ )} +
+
+ + {/* Tabs: Pods / Logs / HPA */} + + + + Pod 列表 ({pods.length}) + + + 日志 {showLogs && `(${logPod})`} + + + HPA ({hpas.length}) + + + + {/* Pods Tab */} + + + + + + + Pod 名称 + 状态 + 节点 + IP + 重启 + 操作 + + + + {pods.length === 0 ? ( + + + 暂无 Pod + + + ) : ( + pods.map((pod: any) => ( + + {pod.name} + + {pod.status} + + {pod.node_name || '-'} + {pod.ip || '-'} + 0 ? 'text-yellow-400' : 'text-slate-500'}> + {pod.restart_count || 0} + + +
+ + +
+
+
+ )) + )} +
+
+
+
+
+ + {/* Logs Tab */} + + + +
+ Pod 日志 +
+ 最近 + setLogTail(Number(e.target.value))} + className="w-20 h-7 bg-slate-800 border-slate-700 text-slate-200 text-xs" + /> + 行 +
+
+
+ + {!showLogs ? ( +
+ +

点击 Pod 列表中的「日志」按钮查看

+
+ ) : ( +
+                  {logs || '无日志内容'}
+                
+ )} +
+
+
+ + {/* HPA Tab */} + + + +
+ HPA 自动扩缩容 + +
+
+ + {hpas.length === 0 ? ( +
+ +

未配置 HPA

+

点击右上角「创建 HPA」设置自动扩缩容

+
+ ) : ( + + + + 名称 + 最小/最大 + 指标 + 当前/目标 + 操作 + + + + {hpas.map((hpa: any) => { + const spec = hpa.spec || {}; + const status = hpa.status || {}; + const metrics = (spec.metrics || []).map((m: any) => { + if (m.resource) return `${m.resource.name}: ${m.resource.target.averageUtilization}%`; + return m.type; + }).join(', '); + return ( + + {hpa.metadata?.name} + {spec.minReplicas} / {spec.maxReplicas} + {metrics || '-'} + {status.currentReplicas || 0} / {status.desiredReplicas || 0} + + + + + ); + })} + +
+ )} +
+
+
+
+
+ ); +} + +// ============================================================================ +// Create HPA Dialog (inline) +// ============================================================================ +function CreateHPADialog({ clusterId, namespace, deploymentName, onCreated }: { + clusterId: string; namespace: string; deploymentName: string; onCreated: () => void; +}) { + const { toast } = useToast(); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [form, setForm] = useState({ + name: '', + minReplicas: 1, + maxReplicas: 10, + cpuPercent: 80, + memPercent: 0, + }); + + const handleCreate = async () => { + if (!form.name.trim()) { + toast({ title: '名称不能为空', variant: 'destructive' }); + return; + } + setLoading(true); + try { + await k8sApi.createHPA(clusterId, { + name: form.name, + namespace, + targetKind: 'Deployment', + targetName: deploymentName, + minReplicas: form.minReplicas, + maxReplicas: form.maxReplicas, + cpuPercent: form.cpuPercent, + memPercent: form.memPercent, + }); + toast({ title: 'HPA 创建成功' }); + setOpen(false); + onCreated(); + setForm({ name: '', minReplicas: 1, maxReplicas: 10, cpuPercent: 80, memPercent: 0 }); + } catch (e: any) { + toast({ title: '创建失败', description: e?.message || String(e), variant: 'destructive' }); + } finally { setLoading(false); } + }; + + return ( + <> + + {open && ( +
setOpen(false)}> +
e.stopPropagation()}> +

创建 HPA

+
+
+ + setForm({ ...form, name: e.target.value })} + placeholder={`hpa-${deploymentName}`} + className="bg-slate-800 border-slate-700 text-slate-200 mt-1" /> +
+
+ +

+ Deployment: {deploymentName} · 命名空间: {namespace} +

+
+
+
+ + setForm({ ...form, minReplicas: Number(e.target.value) })} + className="bg-slate-800 border-slate-700 text-slate-200 mt-1" /> +
+
+ + setForm({ ...form, maxReplicas: Number(e.target.value) })} + className="bg-slate-800 border-slate-700 text-slate-200 mt-1" /> +
+
+
+
+ + setForm({ ...form, cpuPercent: Number(e.target.value) })} + className="bg-slate-800 border-slate-700 text-slate-200 mt-1" /> +
+
+ + setForm({ ...form, memPercent: Number(e.target.value) })} + className="bg-slate-800 border-slate-700 text-slate-200 mt-1" /> +
+
+
+
+ + +
+
+
+ )} + + ); +} diff --git a/frontend/src/components/k8s/K8sWorkloads.tsx b/frontend/src/components/k8s/K8sWorkloads.tsx index 3f65847..45a06f6 100644 --- a/frontend/src/components/k8s/K8sWorkloads.tsx +++ b/frontend/src/components/k8s/K8sWorkloads.tsx @@ -10,10 +10,16 @@ import { import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; -import { RefreshCw, Search, Boxes, Loader2, Container } from 'lucide-react'; +import { + RefreshCw, Search, Boxes, Loader2, Container, + Plus, Minus, RotateCw, Upload, FileText, Eye, +} from 'lucide-react'; import { k8sApi } from '@/services/api'; +import { useToast } from '@/hooks/use-toast'; +import { DeploymentDetail } from './DeploymentDetail'; export function K8sWorkloads() { + const { toast } = useToast(); const [clusters, setClusters] = useState([]); const [selectedCluster, setSelectedCluster] = useState(''); const [deployments, setDeployments] = useState([]); @@ -21,6 +27,10 @@ export function K8sWorkloads() { const [loading, setLoading] = useState(false); const [search, setSearch] = useState(''); const [activeTab, setActiveTab] = useState('deployments'); + const [actionLoading, setActionLoading] = useState>({}); + + // Detail view + const [selectedDeployment, setSelectedDeployment] = useState<{ name: string; namespace: string } | null>(null); const loadClusters = useCallback(async () => { try { @@ -66,6 +76,34 @@ export function K8sWorkloads() { return {status || 'Unknown'}; }; + const handleQuickScale = async (dep: any, delta: number) => { + const key = `scale-${dep.name}`; + setActionLoading((prev) => ({ ...prev, [key]: 'loading' })); + try { + const newReplicas = (dep.replicas || 0) + delta; + if (newReplicas < 0) return; + await k8sApi.scaleDeployment(selectedCluster, dep.namespace, dep.name, newReplicas); + toast({ title: '扩缩容成功', description: `${dep.name} → ${newReplicas} 副本` }); + loadData(); + } catch (e: any) { + toast({ title: '操作失败', description: e?.message || String(e), variant: 'destructive' }); + } finally { + setActionLoading((prev) => { const n = { ...prev }; delete n[key]; return n; }); + } + }; + + // Show detail view if a deployment is selected + if (selectedDeployment) { + return ( + { setSelectedDeployment(null); loadData(); }} + /> + ); + } + return (
@@ -132,7 +170,7 @@ export function K8sWorkloads() { 命名空间 副本 镜像 - 策略 + 操作 @@ -142,7 +180,8 @@ export function K8sWorkloads() { 暂无 Deployment ) : ( filteredDeps.map((d) => ( - + setSelectedDeployment({ name: d.name, namespace: d.namespace })}> {d.name} {d.namespace} @@ -162,7 +201,23 @@ export function K8sWorkloads() { })()} {d.image || '-'} - {d.strategy || 'RollingUpdate'} + e.stopPropagation()}> +
+ + + +
+
)) )} diff --git a/frontend/src/hooks/use-toast.tsx b/frontend/src/hooks/use-toast.tsx new file mode 100644 index 0000000..9b550e7 --- /dev/null +++ b/frontend/src/hooks/use-toast.tsx @@ -0,0 +1,33 @@ +import { useState, useCallback } from 'react'; + +interface Toast { + id: string; + title: string; + description?: string; + variant?: 'default' | 'destructive'; +} + +interface UseToastReturn { + toast: (opts: { title: string; description?: string; variant?: 'default' | 'destructive' }) => void; + toasts: Toast[]; + dismiss: (id: string) => void; +} + +export function useToast(): UseToastReturn { + const [toasts, setToasts] = useState([]); + + const toast = useCallback((opts: { title: string; description?: string; variant?: 'default' | 'destructive' }) => { + const id = Math.random().toString(36).slice(2); + const t: Toast = { id, ...opts }; + setToasts((prev) => [...prev, t]); + setTimeout(() => { + setToasts((prev) => prev.filter((x) => x.id !== id)); + }, 4000); + }, []); + + const dismiss = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + return { toast, toasts, dismiss }; +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index f5b8c5e..8d174c8 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -196,14 +196,60 @@ export const k8sApi = { saveTopology: async (clusterId: number | string, layoutData: string) => { // Logan.Li return http.put(`/k8s-clusters/${clusterId}/topology`, { layout_data: layoutData }); }, - scaleDeployment: async (_clusterId: string, _namespace: string, _name: string, _replicas: number) => { - return { success: true }; + + // === 工作负载操作 (Logan.Li) === + getPodLogs: async (clusterId: number | string, namespace: string, podName: string, tail: number = 200, container?: string) => { + const params = new URLSearchParams({ tail: String(tail) }); + if (container) params.set('container', container); + return http.get(`/k8s-clusters/${clusterId}/namespaces/${namespace}/pods/${podName}/logs?${params}`); }, - restartDeployment: async (_clusterId: string, _namespace: string, _name: string) => { - return { success: true }; + execPod: async (clusterId: number | string, namespace: string, podName: string, command: string[], container?: string) => { + return http.post(`/k8s-clusters/${clusterId}/namespaces/${namespace}/pods/${podName}/exec`, { command, container }); }, - deletePod: async (_clusterId: string, _namespace: string, _name: string) => { - return { success: true }; + scaleDeployment: async (clusterId: number | string, namespace: string, name: string, replicas: number) => { + return http.post(`/k8s-clusters/${clusterId}/namespaces/${namespace}/deployments/${name}/scale`, { replicas }); + }, + restartDeployment: async (clusterId: number | string, namespace: string, name: string) => { + return http.post(`/k8s-clusters/${clusterId}/namespaces/${namespace}/deployments/${name}/restart`); + }, + updateDeploymentImage: async (clusterId: number | string, namespace: string, name: string, image: string, containerName?: string) => { + return http.post(`/k8s-clusters/${clusterId}/namespaces/${namespace}/deployments/${name}/update-image`, { image, container_name: containerName }); + }, + deletePod: async (clusterId: number | string, namespace: string, name: string) => { + return http.delete(`/k8s-clusters/${clusterId}/namespaces/${namespace}/pods/${name}`); + }, + + // === HPA 管理 (Logan.Li) === + getHPAs: async (clusterId: number | string) => { + return http.get(`/k8s-clusters/${clusterId}/hpas`); + }, + createHPA: async (clusterId: number | string, data: { name: string; namespace: string; targetKind: string; targetName: string; minReplicas: number; maxReplicas: number; cpuPercent: number; memPercent: number }) => { + return http.post(`/k8s-clusters/${clusterId}/hpas`, data); + }, + deleteHPA: async (clusterId: number | string, namespace: string, name: string) => { + return http.delete(`/k8s-clusters/${clusterId}/namespaces/${namespace}/hpas/${name}`); + }, + + // === CronJob 管理 (Logan.Li) === + getCronJobs: async (clusterId: number | string) => { + return http.get(`/k8s-clusters/${clusterId}/cronjobs`); + }, + createCronJob: async (clusterId: number | string, data: { name: string; namespace: string; schedule: string; image: string; command: string[] }) => { + return http.post(`/k8s-clusters/${clusterId}/cronjobs`, data); + }, + suspendCronJob: async (clusterId: number | string, namespace: string, name: string, suspend: boolean) => { + return http.post(`/k8s-clusters/${clusterId}/namespaces/${namespace}/cronjobs/${name}/suspend`, { suspend }); + }, + triggerCronJob: async (clusterId: number | string, namespace: string, name: string) => { + return http.post(`/k8s-clusters/${clusterId}/namespaces/${namespace}/cronjobs/${name}/trigger`); + }, + deleteCronJob: async (clusterId: number | string, namespace: string, name: string) => { + return http.delete(`/k8s-clusters/${clusterId}/namespaces/${namespace}/cronjobs/${name}`); + }, + + // === Namespace (Logan.Li) === + getNamespaces: async (clusterId: number | string) => { + return http.get(`/k8s-clusters/${clusterId}/namespaces`); }, };