From d581441fa82cf3dd5ad442a99c313b7a3def29ad Mon Sep 17 00:00:00 2001 From: attacker Date: Wed, 24 Jun 2026 03:55:23 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Deployment=E8=AF=A6=E6=83=85=E9=A1=B5?= =?UTF-8?q?=E9=87=8D=E6=9E=84=20=E2=80=94=20ACK=E9=A3=8E=E6=A0=BCTab?= =?UTF-8?q?=E5=B8=83=E5=B1=80=20+=20CI/CD=20Webhook=20+=20=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeploymentView 完全重写: - Tab布局: 容器组|访问方式(Service/Ingress)|事件|容器伸缩|历史版本 - 顶部信息面板: 状态/副本/节点/IP/创建时间 + Pod条件时间线 - 容器组Tab: 卡片式列表(日志/终端/文件按钮), 展开显示容器详情 - 访问方式Tab: 自动加载匹配的Service+Ingress(selector过滤) - 事件Tab: 加载Deployment相关K8s Events - 容器伸缩Tab: 内联伸缩UI(+/-按钮) - 历史版本Tab: 回滚功能 DeploymentWorkload 精简: - 删除下拉菜单(元数据/运行时/服务/应用路由/YAML预览/日志终端) - 操作按钮改为直接按钮行: 编辑|调整镜像|重启|暂停/恢复|CI/CD|删除 - CI/CD改为Webhook模态(生成URL+curl示例, 自动填充Token) - AIOpsAssistant UUID fallback支持HTTP环境 - Dockerfile chmod a+r 修复favicon.svg 403 --- frontend/Dockerfile | 3 + frontend/src/App.tsx | 1 + .../src/components/aiops/AIOpsAssistant.tsx | 12 +- .../components/kubernetes/DeploymentView.tsx | 830 +++++++++++------- .../kubernetes/DeploymentWorkload.tsx | 142 +-- 5 files changed, 597 insertions(+), 391 deletions(-) diff --git a/frontend/Dockerfile b/frontend/Dockerfile index b76e684..bbc5788 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -17,6 +17,9 @@ FROM nginx:alpine # 复制构建产物 COPY --from=builder /app/dist /usr/share/nginx/html +# 确保 nginx 用户可读(fix favicon.svg 403) +RUN chmod -R a+r /usr/share/nginx/html + # 复制 Nginx 配置 COPY nginx.conf /etc/nginx/conf.d/default.conf diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 966e440..3cb3e8b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -148,6 +148,7 @@ function App() { {/* Quick Access Routes (redirect to first cluster) */} } /> + } /> } /> } /> diff --git a/frontend/src/components/aiops/AIOpsAssistant.tsx b/frontend/src/components/aiops/AIOpsAssistant.tsx index 598fdec..d5cdac3 100644 --- a/frontend/src/components/aiops/AIOpsAssistant.tsx +++ b/frontend/src/components/aiops/AIOpsAssistant.tsx @@ -158,7 +158,15 @@ export function AIOpsAssistant() { const [configReady, setConfigReady] = useState(false); // 已配置且启用 const [contextLoading, setContextLoading] = useState(null); // 快捷操作拉取平台数据中 // 会话 ID:前端生成 UUID,用于隔离多轮对话 // Logan.Li - const [sessionId, setSessionId] = useState(() => crypto.randomUUID()); + // crypto.randomUUID() 需要 HTTPS/secure context,HTTP 环境用 fallback + const generateUUID = (): string => { + try { return crypto.randomUUID(); } catch { /* HTTP fallback below */ } + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + }; + const [sessionId, setSessionId] = useState(() => generateUUID()); const messagesEndRef = useRef(null); // 滚动到底部 // Logan.Li @@ -349,7 +357,7 @@ export function AIOpsAssistant() { } catch { // 清除失败不阻塞本地清空 } - const newSid = crypto.randomUUID(); + const newSid = generateUUID(); setSessionId(newSid); setMessages([]); setInput(''); diff --git a/frontend/src/components/kubernetes/DeploymentView.tsx b/frontend/src/components/kubernetes/DeploymentView.tsx index 6abdf3d..f3598f7 100644 --- a/frontend/src/components/kubernetes/DeploymentView.tsx +++ b/frontend/src/components/kubernetes/DeploymentView.tsx @@ -1,17 +1,22 @@ -// DeploymentView.tsx — 增强只读详情看板(对标 Kuboard) +// DeploymentView.tsx — ACK 风格 Tab 详情看板 +// Tabs: 容器组 | 访问方式 | 事件 | 容器伸缩 | 历史版本 // 作者: Logan -import { useState, Fragment } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +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, FileCode, Server, Container, History, ChevronDown, ChevronRight, - Terminal, FileText, + 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'; @@ -19,17 +24,22 @@ import { formatK8sTime, formatDuration, elapsedSeconds, statusColor, STATUS_COLORS, } from './k8s-yaml-utils'; -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; namespace?: string; replicas?: number; ready_replicas?: number; available_replicas?: number; image?: string; revision?: number; status?: string; created_at?: string; controlled?: boolean; owner_deployment?: string; } - +// 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[]; + clusterCode: string; namespace: string; deployment: any; + pods: PodItem[]; replicaSets: ReplicaSetItem[]; revisions: any[]; onRefresh: () => void; onShowPodLogs: (pod: PodItem) => void; onShowPodTerminal: (pod: PodItem) => void; @@ -37,367 +47,537 @@ interface DeploymentViewProps { onShowPodFileBrowser: (pod: PodItem) => void; } -/** 计算 Pod 从调度到就绪的各阶段耗时(基于条件时间线,兼容扁平字段) */ +// ─── 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 scheduled = find('PodScheduled') || {}; - const initialized = find('Initialized') || {}; - const ready = find('Ready') || {}; - const containersReady = find('ContainersReady') || {}; const ts = (c: any) => c?.lastTransitionTime || c?.LastTransitionTime || c?.last_transition_time; return { - scheduled: ts(scheduled), - initialized: ts(initialized), - containersReady: ts(containersReady), - ready: ts(ready), + scheduled: ts(find('PodScheduled') || {}), + initialized: ts(find('Initialized') || {}), + containersReady: ts(find('ContainersReady') || {}), + ready: ts(find('Ready') || {}), startTime: pod.start_time || (pod as any).startTime, }; } -function StatCard({ label, value, sub, color }: { label: string; value: React.ReactNode; sub?: string; color?: string }) { - return ( - - -

{label}

-

{value}

- {sub &&

{sub}

} -
-
- ); -} - -export function DeploymentView({ - clusterCode, namespace, deployment, pods, replicaSets, revisions, - onRefresh, onShowPodLogs, onShowPodTerminal, onShowPodYaml, onShowPodFileBrowser, -}: DeploymentViewProps) { - const navigate = useNavigate(); +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); - const ready = deployment.ready_replicas || deployment.readyReplicas || 0; - const total = deployment.replicas || 0; - const available = deployment.available_replicas || deployment.availableReplicas || 0; - const updated = deployment.updated_replicas || deployment.updatedReplicas || 0; + // 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; - const createdAt = deployment.created_at || deployment.creationTimestamp || deployment.created_at; - const updatedAt = deployment.updated_at || deployment.last_updated; + // 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}?\n镜像: ${rev.image || '-'}`)) return; + if (!confirm(`确认回滚到修订版本 #${revision}?`)) return; setRollbackLoading(revision); try { await kubernetesApi.rollbackDeployment(clusterCode, namespace, deployment.name, Number(revision)); - toast({ title: '回滚指令已下发', description: `正在回滚到 revision #${revision}` }); + toast({ title: '回滚已下发', description: `正在回滚到 #${revision}` }); onRefresh(); } catch (e: any) { toast({ title: '回滚失败', description: e?.message || String(e), variant: 'destructive' }); } finally { setRollbackLoading(null); } }; - const nodeClick = (nodeName: string | undefined) => { - if (!nodeName) return; - navigate(`/kubernetes/${clusterCode}`); + // 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 (
- {/* ============ 1. 状态总览 ============ */} -
- - - - - {formatK8sTime(createdAt)}} color="#94a3b8" /> - {formatK8sTime(updatedAt)}} color="#94a3b8" /> + {/* ============ 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 => ( + + ))}
- {/* ============ 2. 容器详情(CPU/内存/镜像/端口) ============ */} - - - 容器模板(Pod Template) - - - - - - - {/* ============ 3. Pod 状态时间线 + 详情列表 ============ */} - - - - Pod 实例 ({pods.length}) - - - - - - - - Pod 名称 - 状态 - 所在节点 - Pod IP - 重启 - 调度→就绪 - 启动时间 - 操作 - - - - {pods.length === 0 ? ( - 暂无 Pod - ) : pods.map((pod) => { - const tl = getPodTimeline(pod); - const schedToReady = elapsedSeconds(tl.scheduled, tl.ready); - const expanded = expandedPod === pod.name; - const nodeName = pod.node_name || pod.node; - return ( - - - - - - {pod.name} - - - {pod.status || 'Unknown'} - - - - - - {pod.ip || pod.pod_ip || '-'} - 0 ? 'text-yellow-400' : 'text-slate-500'}>{pod.restart_count || 0} - {formatDuration(schedToReady)} - {formatK8sTime(tl.startTime || pod.created_at)} - -
- - - -
-
-
- {expanded && ( - - - - - - )} -
- ); - })} -
-
-
-
- - {/* ============ 4. 历史版本(可回滚) ============ */} - - - 历史版本(Revision / Rollout) - - - - - - Revision - 镜像 - 状态 - 更新时间 - 操作 - - - - {revisions.length === 0 ? ( - 后端 /revisions 接口尚未就绪,等待后端联调 - ) : 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)} - - - - - ); - })} - -
-
-
- - {/* ============ 5. ReplicaSet 列表 ============ */} - - - ReplicaSet 列表 ({replicaSets.length}) - - - - - - 名称 - 副本 - 镜像 - Revision - 状态 - 创建时间 - - - - {replicaSets.length === 0 ? ( - 后端 /replicasets 接口尚未就绪,等待后端联调 - ) : replicaSets.map((rs) => { - const r: any = rs; - const rsReady = r.ready_replicas ?? r.readyReplicas ?? 0; - const rsTotal = r.replicas ?? 0; - const ok = rsReady === rsTotal && rsTotal > 0; - return ( - - - {r.name} - {r.controlled && 当前} - - {rsReady}/{rsTotal} - {r.image || '-'} - #{r.revision || '-'} - - - {r.status || (ok ? 'Ready' : 'Updating')} - - - {formatK8sTime(r.created_at)} - - ); - })} - -
-
-
+ {/* ============ Tab Content ============ */} + {tab === 'pods' && } + {tab === 'services' && } + {tab === 'events' && } + {tab === 'scale' && ( + + )} + {tab === 'history' && }
); } -// ============ 容器模板表(镜像/端口/资源/探针) ============ -function ContainersTable({ deployment }: { deployment: any }) { - const containers: any[] = deployment.containers || deployment.container_specs || deployment.spec_containers || []; - if (containers.length === 0) { - return

无容器详情数据(扁平字段缺少 containers,可查看 YAML)

; - } +// ─── Info Item ─── +function InfoItem({ label, value }: { label: string; value: React.ReactNode }) { return ( -
- {containers.map((c, idx) => { - const cpuReq = c.resources?.requests?.cpu || c.cpu_request; - const cpuLim = c.resources?.limits?.cpu || c.cpu_limit; - const memReq = c.resources?.requests?.memory || c.memory_request; - const memLim = c.resources?.limits?.memory || c.memory_limit; - const ports: any[] = c.ports || c.container_ports || []; +
+

{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 ( -
-
-
- - {c.name || `container-${idx}`} -
- {c.image} + +
+
+ {s.label} + {s.ts ? formatK8sTime(s.ts) : '-'}
-
-
- - CPU: {cpuReq || '-'}/{cpuLim || '-'} + {i < stages.length - 1 && ( +
+
+ {dur != null ? formatDuration(dur) : ''}
-
- - 内存: {memReq || '-'}/{memLim || '-'} -
-
- - 端口: {ports.map((p: any) => typeof p === 'object' ? (p.containerPort || p.port) : p).join(', ') || '-'} -
-
- - 挂载: {(c.volume_mounts || c.volumeMounts || []).length || 0} -
-
- {/* 探针 */} -
- {c.liveness_probe || c.livenessProbe ? ( - ● 存活探针 - ) : ○ 无存活探针} - {c.readiness_probe || c.readinessProbe ? ( - ● 就绪探针 - ) : ○ 无就绪探针} - {c.startup_probe || c.startupProbe ? ( - ● 启动探针 - ) : null} -
-
+ )} + ); })}
); } -// ============ Pod 状态时间线(调度→初始化→容器就绪→就绪) ============ -function PodTimeline({ 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: 'Pod 就绪', ts: tl.ready, color: STATUS_COLORS.green }, - ]; +// ═══════════════ 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 ( -
-

状态时间线

-
- {stages.map((s, i) => { - const prev = stages[i - 1]; - const dur = elapsedSeconds(prev?.ts, s.ts); - return ( -
-
-
- {s.label} - {s.ts ? formatK8sTime(s.ts) : '-'} +
+ {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)} +
+ + +
- {i < stages.length - 1 && ( -
-
- {dur != null ? formatDuration(dur) : ''} + {/* 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)} + + + + + ); + })} + +
+
+ ); +} diff --git a/frontend/src/components/kubernetes/DeploymentWorkload.tsx b/frontend/src/components/kubernetes/DeploymentWorkload.tsx index 0c3b4aa..83257ce 100644 --- a/frontend/src/components/kubernetes/DeploymentWorkload.tsx +++ b/frontend/src/components/kubernetes/DeploymentWorkload.tsx @@ -52,6 +52,7 @@ type ActionModal = | { kind: 'podLogs'; pod: string } | { kind: 'podYaml'; pod: string } | { kind: 'delete' } + | { kind: 'cicd' } | null; export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mode, onBack, onModeChange }: DeploymentWorkloadProps) { @@ -194,8 +195,8 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod }; const handleCICD = () => { - // 跳转到流水线页面,传递 namespace + deployment 上下文 - navigate(`/pipelines?namespace=${encodeURIComponent(namespace)}&app=${encodeURIComponent(deploymentName)}`); + // 打开 Webhook 弹窗(不跳转流水线) + setModal({ kind: 'cicd' as any }); }; const handleUpdateImage = async () => { @@ -327,68 +328,35 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod - {/* 顶部操作菜单(Kuboard 风格下拉) */} - - - - - - onModeChange('edit')}> - 编辑 - - setModal({ kind: 'yaml' })}> - 预览 / 编辑 YAML - - setModal({ kind: 'image' })}> - 调整镜像版本 - - { setScaleValue(deployment.replicas || 0); setModal({ kind: 'scale' }); }}> - 伸缩 - - toast({ title: '日志 / 终端', description: '请在 Pod 列表中点击对应按钮' })}> - 日志 / 终端 - - - 重启 - - - {deployment?.paused - ? <> 恢复更新 - : <> 暂停更新 - } - - - CI/CD 发布 - - setModal({ kind: 'delete' })}> - 删除 - - - toast({ title: '元数据', description: 'Labels / Annotations / Selectors,集成在 YAML 视图中' })}> - 元数据 - - toast({ title: '运行时', description: 'Pod 运行状态请见 Pod 实例区' })}> - 运行时 - - navigate(`${viewBase}/view/Service?selector=${encodeURIComponent(JSON.stringify(deployment?.spec?.selector?.matchLabels || deployment?.spec?.template?.metadata?.labels || {}))}`)}> - 服务 - - navigate(`${viewBase}/view/Ingress?namespace=${namespace}`)}> - 应用路由 - - - + {/* 操作按钮行 */} +
+ + + + + + +
@@ -521,6 +489,13 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod )} + {/* ============ CI/CD Webhook 弹窗 ============ */} + {modal?.kind === 'cicd' && ( + setModal(null)}> + + + )} + {/* ============ Pod 日志 ============ */} {modal?.kind === 'podLogs' && ( s.token) || localStorage.getItem('token') || ''; + const webhookUrl = `${window.location.origin}/api/v1/clusters/${clusterCode}/namespaces/${namespace}/deployments/${deploymentName}/update-image`; + const curlCmd = `curl -X POST '${webhookUrl}' \\ + -H 'Authorization: Bearer ${token ? token.substring(0, 16) + '...' : 'YOUR_TOKEN'}' \\ + -H 'Content-Type: application/json' \\ + -d '{"image": "harbor.example.com/app:v1.0", "container": "your-container-name"}'`; + + const copy = (text: string) => { + navigator.clipboard.writeText(text).then(() => toast({ title: '已复制' })).catch(() => {}); + }; + + return ( +
+
+ +
+ {webhookUrl} + +
+
+
+ +
+
{curlCmd}
+ +
+
+
+

• 使用 Bearer Token 认证(JWT 登录令牌)

+

• image 必填,指定新镜像地址

+

• container 可选,不填则更新第一个容器

+
+
+ ); +} + // ============ 通用 Modal 外壳 ============ function ModalShell({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) { return (