// DeploymentView.tsx — ACK 风格 Tab 详情看板 // Tabs: 容器组 | 访问方式 | 事件 | 容器伸缩 | 历史版本 // 作者: Logan import { useState, useEffect, Fragment } from 'react'; import { Card, CardContent, CardHeader, CardTitle, } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { Clock, Cpu, MemoryStick, HardDrive, Network, Activity, RotateCcw, Container, History, ChevronDown, ChevronRight, Terminal, FileText, Server, ExternalLink, Minus, Plus, Loader2, } from 'lucide-react'; import { kubernetesApi } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; import { formatK8sTime, formatDuration, elapsedSeconds, statusColor, STATUS_COLORS, } from './k8s-yaml-utils'; // Types interface PodItem { name: string; namespace?: string; status?: string; node_name?: string; node?: string; ip?: string; pod_ip?: string; restart_count?: number; age?: string; created_at?: string; start_time?: string; containers?: any[]; image?: string; images?: string[]; } interface ReplicaSetItem { name: string; replicas?: number; ready_replicas?: number; available_replicas?: number; image?: string; revision?: number; status?: string; created_at?: string; } interface SvcItem { name: string; type: string; clusterIP: string; ports: any[]; selector: Record; age: string; } interface IngItem { name: string; className: string; hosts: string[]; rules: any[]; age: string; } interface EventItem { type: string; reason: string; message: string; source: string; firstTimestamp: string; lastTimestamp: string; count: number; } interface DeploymentViewProps { clusterCode: string; namespace: string; deployment: any; pods: PodItem[]; replicaSets: ReplicaSetItem[]; revisions: any[]; onRefresh: () => void; onShowPodLogs: (pod: PodItem) => void; onShowPodTerminal: (pod: PodItem) => void; onShowPodYaml: (pod: PodItem) => void; onShowPodFileBrowser: (pod: PodItem) => void; } // ─── Helpers ─── function getPodTimeline(pod: PodItem) { const cond: any[] = (pod as any).conditions || (pod as any).pod_conditions || []; const find = (t: string) => cond.find((c: any) => (c.type || c.Type) === t); const ts = (c: any) => c?.lastTransitionTime || c?.LastTransitionTime || c?.last_transition_time; return { scheduled: ts(find('PodScheduled') || {}), initialized: ts(find('Initialized') || {}), containersReady: ts(find('ContainersReady') || {}), ready: ts(find('Ready') || {}), startTime: pod.start_time || (pod as any).startTime, }; } export function DeploymentView(props: DeploymentViewProps) { const { clusterCode, namespace, deployment, pods, replicaSets, revisions, onRefresh, onShowPodLogs, onShowPodTerminal, onShowPodYaml, onShowPodFileBrowser } = props; const { toast } = useToast(); const [tab, setTab] = useState('pods'); const [expandedPod, setExpandedPod] = useState(null); const [rollbackLoading, setRollbackLoading] = useState(null); // Scale state const [scaleValue, setScaleValue] = useState(deployment?.replicas || 0); const [scaleLoading, setScaleLoading] = useState(false); // Service/Ingress/Events state const [services, setServices] = useState([]); const [ingresses, setIngresses] = useState([]); const [events, setEvents] = useState([]); const [tabLoading, setTabLoading] = useState(''); const ready = deployment?.ready_replicas ?? deployment?.readyReplicas ?? 0; const total = deployment?.replicas ?? 0; const isHealthy = ready === total && total > 0; // Load Services and Ingresses for "访问方式" tab useEffect(() => { if (tab !== 'services') return; setTabLoading('services'); Promise.all([ kubernetesApi.getNamespaceServices(clusterCode, namespace).catch(() => []), kubernetesApi.getNamespaceIngresses(clusterCode, namespace).catch(() => []), ]).then(([svcRes, ingRes]) => { const toArr = (v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || [])); const allSvcs = toArr(svcRes); const allIngs = toArr(ingRes); // Filter services matching deployment selector const selector = deployment?.spec?.selector?.matchLabels || deployment?.spec?.template?.metadata?.labels || {}; const matchedSvcs = allSvcs.filter((s: any) => { const svcSel = s.spec?.selector || s.selector || {}; if (Object.keys(svcSel).length === 0) return false; return Object.entries(svcSel).every(([k, v]) => selector[k] === v); }).map((s: any) => ({ name: s.name || s.metadata?.name, type: s.spec?.type || s.type || 'ClusterIP', clusterIP: s.spec?.clusterIP || s.cluster_ip || '', ports: (s.spec?.ports || s.ports || []).map((p: any) => `${p.port}${p.targetPort ? ':' + p.targetPort : ''}/${p.protocol || 'TCP'}`), selector: s.spec?.selector || s.selector || {}, age: s.age || '', })); // Filter ingresses pointing to matched services const svcNames = new Set(matchedSvcs.map((s: any) => s.name)); const matchedIngs = allIngs.filter((i: any) => { const rules = i.spec?.rules || i.rules || []; return rules.some((r: any) => (r.http?.paths || r.paths || []).some((p: any) => { const backendSvc = p.backend?.service?.name || p.backendService || ''; return svcNames.has(backendSvc); }) ); }).map((i: any) => ({ name: i.name || i.metadata?.name, className: i.spec?.ingressClassName || i.ingressClass || '-', hosts: (i.spec?.rules || i.rules || []).map((r: any) => r.host).filter(Boolean), rules: i.spec?.rules || i.rules || [], age: i.age || '', })); setServices(matchedSvcs); setIngresses(matchedIngs); }).catch(() => {}).finally(() => setTabLoading('')); }, [tab, clusterCode, namespace, deployment]); // Load Events for "事件" tab useEffect(() => { if (tab !== 'events') return; setTabLoading('events'); kubernetesApi.getEvents(clusterCode, namespace, undefined).then((res: any) => { const allEvents = Array.isArray(res) ? res : (res?.list || res?.items || res?.data || []); const depEvents = allEvents.filter((e: any) => { const involved = e.involvedObject || e.involved_object || {}; const objName = involved.name || ''; return objName === deployment?.name || objName.startsWith(deployment?.name + '-'); }).map((e: any) => ({ type: e.type || 'Normal', reason: e.reason || '', message: e.message || '', source: (e.source?.component || e.sourceComponent || '-'), firstTimestamp: e.firstTimestamp || e.first_timestamp || '', lastTimestamp: e.lastTimestamp || e.last_timestamp || '', count: e.count || 1, })); setEvents(depEvents); }).catch(() => {}).finally(() => setTabLoading('')); }, [tab, clusterCode, namespace, deployment?.name]); // Rollback const handleRollback = async (rev: any) => { const revision = rev.revision ?? rev.revision_number ?? rev.number; if (!confirm(`确认回滚到修订版本 #${revision}?`)) return; setRollbackLoading(revision); try { await kubernetesApi.rollbackDeployment(clusterCode, namespace, deployment.name, Number(revision)); toast({ title: '回滚已下发', description: `正在回滚到 #${revision}` }); onRefresh(); } catch (e: any) { toast({ title: '回滚失败', description: e?.message || String(e), variant: 'destructive' }); } finally { setRollbackLoading(null); } }; // Scale const handleScale = async () => { setScaleLoading(true); try { await kubernetesApi.scaleDeployment(clusterCode, namespace, deployment.name, scaleValue); toast({ title: '伸缩已提交', description: `副本数 → ${scaleValue}` }); onRefresh(); } catch (e: any) { toast({ title: '伸缩失败', description: e?.message || String(e), variant: 'destructive' }); } finally { setScaleLoading(false); } }; const TABS = [ { key: 'pods', label: '容器组', icon: Container, count: pods.length }, { key: 'services', label: '访问方式', icon: Network, count: services.length }, { key: 'events', label: '事件', icon: Activity, count: events.length }, { key: 'scale', label: '容器伸缩', icon: Plus }, { key: 'history', label: '历史版本', icon: History, count: revisions.length }, ]; return (
{/* ============ Info Panel (ACK-style) ============ */}
{isHealthy ? 'Running' : 'Updating'} } /> {ready}/{total}} /> {pods[0] && ( <> window.open(`/kubernetes/${clusterCode}/nodes/${pods[0].node_name || pods[0].node}`, '_blank')}> {pods[0].node_name || pods[0].node || '-'} } /> {pods[0].ip || pods[0].pod_ip || '-'}} /> )}
{/* Pod conditions timeline */} {pods[0] && (
)}
{/* ============ Tab Bar ============ */}
{TABS.map(t => ( ))}
{/* ============ Tab Content ============ */} {tab === 'pods' && } {tab === 'services' && } {tab === 'events' && } {tab === 'scale' && ( )} {tab === 'history' && }
); } // ─── Info Item ─── function InfoItem({ label, value }: { label: string; value: React.ReactNode }) { return (

{label}

{value}
); } // ─── Pod Conditions Row ─── function PodConditionsRow({ pod }: { pod: PodItem }) { const tl = getPodTimeline(pod); const stages = [ { label: '已调度', ts: tl.scheduled, color: STATUS_COLORS.blue }, { label: '已初始化', ts: tl.initialized, color: STATUS_COLORS.orange }, { label: '容器已就绪', ts: tl.containersReady, color: STATUS_COLORS.green }, { label: '容器组已就绪', ts: tl.ready, color: STATUS_COLORS.green }, ]; return (
{stages.map((s, i) => { const prev = stages[i - 1]; const dur = elapsedSeconds(prev?.ts, s.ts); return (
{s.label} {s.ts ? formatK8sTime(s.ts) : '-'}
{i < stages.length - 1 && (
{dur != null ? formatDuration(dur) : ''}
)} ); })}
); } // ═══════════════ TAB: 容器组 ═══════════════ function PodsTab({ clusterCode, namespace, pods, onShowPodLogs, onShowPodTerminal, onShowPodYaml, onShowPodFileBrowser, expandedPod, setExpandedPod }: DeploymentViewProps & { expandedPod: string | null; setExpandedPod: (v: string | null) => void }) { if (pods.length === 0) { return
暂无容器组
; } return (
{pods.map(pod => { const tl = getPodTimeline(pod); const expanded = expandedPod === pod.name; const nodeName = pod.node_name || pod.node; return ( {/* Pod header row */}
{pod.name} {pod.status || 'Unknown'} {pod.ip || pod.pod_ip || '-'} 重启 {pod.restart_count || 0} 次 {formatK8sTime(tl.startTime || pod.created_at)}
{/* Expanded: timeline */} {expanded && (
{/* Container details */} {(pod as any).containers && ((pod as any).containers as any[]).length > 0 && (
{((pod as any).containers as any[]).map((c: any, idx: number) => (
{c.name} {c.ready ? 'Ready' : 'Pending'}
{c.image}
{c.resources && (
CPU: {c.resources?.requests?.cpu || '0'}/{c.resources?.limits?.cpu || '-'} m 内存: {c.resources?.requests?.memory || '0'}/{c.resources?.limits?.memory || '-'}
)}
))}
)}
)} ); })}
); } // ═══════════════ TAB: 访问方式 ═══════════════ function ServicesTab({ services, ingresses, loading, namespace }: { services: SvcItem[]; ingresses: IngItem[]; loading: string; namespace: string }) { if (loading === 'services') { return
; } return (
{/* Services */}

关联 Service

{services.length === 0 ? (

暂无匹配的 Service(Deployment 选择器未关联任何 Service)

) : (
名称 类型 ClusterIP 端口 Selector {services.map(svc => ( {svc.name} {svc.type} {svc.clusterIP} {svc.ports.join(', ')} {Object.entries(svc.selector).map(([k, v]) => ( {k}={v} ))} ))}
)}
{/* Ingresses */}

关联 Ingress

{ingresses.length === 0 ? (

暂无匹配的 Ingress

) : (
名称 Class Host 路径 {ingresses.map(ing => ( {ing.name} {ing.className} {ing.hosts.join(', ')} {ing.rules.flatMap((r: any) => (r.http?.paths || r.paths || []).map((p: any) => p.path || '/')).join(', ')} ))}
)}
); } // ═══════════════ TAB: 事件 ═══════════════ function EventsTab({ events, loading }: { events: EventItem[]; loading: string }) { if (loading === 'events') { return
; } if (events.length === 0) { return
暂无事件
; } return (
类型 原因 消息 来源 次数 最后时间 {events.map((e, i) => ( {e.type} {e.reason} {e.message} {e.source} {e.count} {formatK8sTime(e.lastTimestamp)} ))}
); } // ═══════════════ TAB: 容器伸缩 ═══════════════ function ScaleTab({ current, value, onChange, onApply, loading }: { current: number; value: number; onChange: (v: number) => void; onApply: () => void; loading: boolean }) { return (

当前副本数: {current}

onChange(Math.max(0, Number(e.target.value)))} className="w-28 text-center text-2xl font-bold" />
); } // ═══════════════ TAB: 历史版本 ═══════════════ function HistoryTab({ revisions, rollbackLoading, onRollback }: { revisions: any[]; rollbackLoading: number | null; onRollback: (rev: any) => void }) { if (revisions.length === 0) { return
暂无历史版本
; } return (
Revision 镜像 状态 更新时间 操作 {revisions.map((rev, idx) => { const revision = rev.revision ?? rev.revision_number ?? rev.number ?? idx + 1; const isLatest = idx === 0; return ( #{revision} {rev.image || rev.images?.[0] || '-'} {isLatest ? 当前 : 历史} {formatK8sTime(rev.created_at || rev.time)} ); })}
); }