// 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { Clock, HardDrive, Network, Activity, RotateCcw, Container, History, ChevronDown, ChevronRight, Terminal, FileText, 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: Record[] = (pod as Record).conditions || (pod as Record).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 Record).startTime, }; } export function DeploymentView(props: DeploymentViewProps) { const { clusterCode, namespace, deployment, pods, revisions, onRefresh } = 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(''); // HPA / CronHPA state const [hpas, setHpas] = useState([]); const [cronHpas, setCronHpas] = 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]); // Load HPA and CronHPA for "伸缩" tab useEffect(() => { if (tab !== 'scale') return; setTabLoading('scale'); Promise.all([ kubernetesApi.getHPAs(clusterCode, namespace).catch(() => []), Promise.resolve([]), // CronHPA 待后端支持 ]).then(([hpaRes]) => { const toArr = ( v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || [])); const allHpas = toArr(hpaRes); const depName = deployment?.name || ''; setHpas(allHpas.filter(( h: any) => (h.scaleTargetRef?.name || h.targetRef?.name || '') === depName)); setCronHpas([]); }).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 行右侧 */}
setScaleValue(Math.max(0, Number(e.target.value)))} className="w-16 h-7 text-center text-sm font-bold bg-slate-800 border-slate-700 text-slate-200" /> {scaleValue !== total && ( )}
{/* ============ 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, pods, onShowPodLogs, onShowPodTerminal, 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 Record).containers && ((pod as Record).containers as any[]).length > 0 && (
{((pod as Record).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 }: { 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: 伸缩 (HPA + CronHPA) ═══════════════ function HPACronHPATab({ hpas, cronHpas, loading, replicas }: { hpas: any[]; cronHpas: any[]; loading: string; replicas: number }) { if (loading === 'scale') { return
; } return (
{/* 当前副本数提示 */}
当前副本: {replicas} (可通过 Tab 栏右侧 +/- 手动调整)
{/* HPA 策略 */} HPA — 水平自动伸缩 {hpas.length === 0 ? (
未关联 HPA —
) : (
名称 最小/最大 指标 当前负载 状态 {hpas.map(( hpa: any, i: number) => { const minR = hpa.spec?.minReplicas ?? hpa.minReplicas ?? 1; const maxR = hpa.spec?.maxReplicas ?? hpa.maxReplicas ?? 10; const metrics = hpa.spec?.metrics || hpa.metrics || []; const curR = hpa.status?.currentReplicas ?? hpa.currentReplicas ?? 0; const conditions = hpa.status?.conditions || hpa.conditions || []; const ableToScale = conditions.find(( c: any) => c.type === 'AbleToScale'); const ok = ableToScale?.status === 'True'; return ( {hpa.name || hpa.metadata?.name} {minR} → {maxR} {metrics.map(( m: any, j: number) => { const resource = m.resource || m.object?.metric?.name || ''; const target = m.resource?.target?.averageUtilization || m.resource?.target?.averageValue || m.resource?.target?.type || '-'; return
{resource?.name || resource}: {target}{m.resource?.target?.averageUtilization ? '%' : ''}
; })}
{curR} {ok ? '正常' : '异常'}
); })}
)}
{/* CronHPA 策略 */} CronHPA — 定时伸缩 {cronHpas.length === 0 ? (
未关联 CronHPA —
) : (
名称 目标副本 调度规则 时区 状态 {cronHpas.map(( cron: any, i: number) => { const jobs = cron.spec?.jobs || cron.jobs || []; const tz = cron.spec?.timezone || 'UTC'; return ( {cron.name || cron.metadata?.name} { jobs.map(( j: any) => j.targetSize || j.targetReplicas || '?').join(', ') } {jobs.map(( j: any) => j.schedule || '?').join(', ')} {tz} 已启用 ); })}
)}
); } // ═══════════════ 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)} ); })}
); }