Files
aiops/frontend/src/components/kubernetes/DeploymentView.tsx
T
attacker ce438c9f05 fix: K8s 集群添加缺少 code 必填字段
- KubernetesClusterDialog: 新增 code 表单字段
- 名称变更自动生成 code(小写+连字符)
- handleSubmit 增加 code 校验 + toast 提示
- payload 增加 code 字段(后端 binding:required)
2026-06-21 09:50:07 +00:00

396 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// DeploymentView.tsx — 增强只读详情看板(对标 Kuboard)
// 作者: Logan <admin@attacker.club>
import { useState, Fragment } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
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,
} 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';
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; }
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;
}
/** 计算 Pod 从调度到就绪的各阶段耗时(基于条件时间线,兼容扁平字段) */
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),
startTime: pod.start_time || (pod as any).startTime,
};
}
function StatCard({ label, value, sub, color }: { label: string; value: React.ReactNode; sub?: string; color?: string }) {
return (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-3">
<p className="text-xs text-slate-500">{label}</p>
<p className="text-base font-bold mt-0.5" style={{ color: color || '#e2e8f0' }}>{value}</p>
{sub && <p className="text-[10px] text-slate-600 mt-0.5">{sub}</p>}
</CardContent>
</Card>
);
}
export function DeploymentView({
clusterCode, namespace, deployment, pods, replicaSets, revisions,
onRefresh, onShowPodLogs, onShowPodTerminal, onShowPodYaml,
}: DeploymentViewProps) {
const navigate = useNavigate();
const { toast } = useToast();
const [expandedPod, setExpandedPod] = useState<string | null>(null);
const [rollbackLoading, setRollbackLoading] = useState<number | null>(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;
const isHealthy = ready === total && total > 0;
const createdAt = deployment.created_at || deployment.creationTimestamp || deployment.created_at;
const updatedAt = deployment.updated_at || deployment.last_updated;
const handleRollback = async (rev: any) => {
const revision = rev.revision ?? rev.revision_number ?? rev.number;
if (!confirm(`确认回滚到修订版本 #${revision}?\n镜像: ${rev.image || '-'}`)) return;
setRollbackLoading(revision);
try {
await kubernetesApi.rollbackDeployment(clusterCode, namespace, deployment.name, Number(revision));
toast({ title: '回滚指令已下发', description: `正在回滚到 revision #${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}`);
};
return (
<div className="space-y-4">
{/* ============ 1. 状态总览 ============ */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
<StatCard label="副本就绪" value={`${ready}/${total}`} color={isHealthy ? STATUS_COLORS.green : STATUS_COLORS.orange} sub={isHealthy ? '全部就绪' : '部分未就绪'} />
<StatCard label="可用副本" value={available} color={STATUS_COLORS.blue} />
<StatCard label="已更新副本" value={updated} color={STATUS_COLORS.green} />
<StatCard label="策略" value={deployment.strategy || 'RollingUpdate'} color="#94a3b8" />
<StatCard label="创建时间" value={<span className="text-xs">{formatK8sTime(createdAt)}</span>} color="#94a3b8" />
<StatCard label="更新时间" value={<span className="text-xs">{formatK8sTime(updatedAt)}</span>} color="#94a3b8" />
</div>
{/* ============ 2. 容器详情(CPU/内存/镜像/端口) ============ */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2"><Container className="h-4 w-4 text-blue-400" />容器模板(Pod Template)</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<ContainersTable deployment={deployment} />
</CardContent>
</Card>
{/* ============ 3. Pod 状态时间线 + 详情列表 ============ */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<Activity className="h-4 w-4 text-emerald-400" />Pod 实例 ({pods.length})
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400 w-8"></TableHead>
<TableHead className="text-slate-400">Pod 名称</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400">所在节点</TableHead>
<TableHead className="text-slate-400">Pod IP</TableHead>
<TableHead className="text-slate-400">重启</TableHead>
<TableHead className="text-slate-400">调度→就绪</TableHead>
<TableHead className="text-slate-400">启动时间</TableHead>
<TableHead className="text-slate-400">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pods.length === 0 ? (
<TableRow><TableCell colSpan={9} className="text-center text-slate-500 py-8">暂无 Pod</TableCell></TableRow>
) : 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 (
<Fragment key={pod.name}>
<TableRow className="border-slate-800 hover:bg-slate-800/30">
<TableCell>
<Button variant="ghost" size="icon" className="h-6 w-6 text-slate-500" onClick={() => setExpandedPod(expanded ? null : pod.name)}>
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</Button>
</TableCell>
<TableCell className="font-medium text-slate-200">{pod.name}</TableCell>
<TableCell>
<Badge style={{ background: `${statusColor(pod.status)}22`, color: statusColor(pod.status), border: `1px solid ${statusColor(pod.status)}44` }}>
{pod.status || 'Unknown'}
</Badge>
</TableCell>
<TableCell>
<button className="text-sm text-blue-400 hover:underline inline-flex items-center gap-1" onClick={() => nodeClick(nodeName)} title="跳转节点详情">
<Server className="h-3 w-3" />{nodeName || '-'}
</button>
</TableCell>
<TableCell className="text-sm text-slate-400 font-mono">{pod.ip || pod.pod_ip || '-'}</TableCell>
<TableCell className={(pod.restart_count || 0) > 0 ? 'text-yellow-400' : 'text-slate-500'}>{pod.restart_count || 0}</TableCell>
<TableCell className="text-xs text-slate-400">{formatDuration(schedToReady)}</TableCell>
<TableCell className="text-xs text-slate-500">{formatK8sTime(tl.startTime || pod.created_at)}</TableCell>
<TableCell>
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="日志" onClick={() => onShowPodLogs(pod)}><FileCode className="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-emerald-400" title="终端" onClick={() => onShowPodTerminal(pod)}><Container className="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-amber-400" title="YAML" onClick={() => onShowPodYaml(pod)}><FileCode className="h-3.5 w-3.5" /></Button>
</div>
</TableCell>
</TableRow>
{expanded && (
<TableRow className="border-slate-800 bg-slate-950/40">
<TableCell colSpan={9} className="p-4">
<PodTimeline pod={pod} />
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
{/* ============ 4. 历史版本(可回滚) ============ */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2"><History className="h-4 w-4 text-purple-400" />历史版本(Revision / Rollout)</CardTitle>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">Revision</TableHead>
<TableHead className="text-slate-400">镜像</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400">更新时间</TableHead>
<TableHead className="text-slate-400">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{revisions.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center text-slate-500 py-6 text-xs">后端 /revisions 接口尚未就绪,等待后端联调</TableCell></TableRow>
) : revisions.map((rev, idx) => {
const revision = rev.revision ?? rev.revision_number ?? rev.number ?? idx + 1;
const isLatest = idx === 0;
return (
<TableRow key={revision} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-mono text-slate-200">#{revision}</TableCell>
<TableCell className="text-xs font-mono text-slate-400 max-w-[280px] truncate">{rev.image || rev.images?.[0] || '-'}</TableCell>
<TableCell>
{isLatest
? <Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30">当前</Badge>
: <Badge className="bg-slate-700/40 text-slate-400 border-slate-600">历史</Badge>}
</TableCell>
<TableCell className="text-xs text-slate-500">{formatK8sTime(rev.created_at || rev.time)}</TableCell>
<TableCell>
<Button variant="ghost" size="sm" className="h-7 text-amber-400 hover:text-amber-300 disabled:opacity-40"
disabled={isLatest || rollbackLoading !== null}
onClick={() => handleRollback(rev)}>
{rollbackLoading === revision ? '回滚中...' : <><RotateCcw className="h-3 w-3 mr-1" />回滚</>}
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
{/* ============ 5. ReplicaSet 列表 ============ */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2"><Container className="h-4 w-4 text-cyan-400" />ReplicaSet 列表 ({replicaSets.length})</CardTitle>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">名称</TableHead>
<TableHead className="text-slate-400">副本</TableHead>
<TableHead className="text-slate-400">镜像</TableHead>
<TableHead className="text-slate-400">Revision</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400">创建时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{replicaSets.length === 0 ? (
<TableRow><TableCell colSpan={6} className="text-center text-slate-500 py-6 text-xs">后端 /replicasets 接口尚未就绪,等待后端联调</TableCell></TableRow>
) : 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 (
<TableRow key={r.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">
{r.name}
{r.controlled && <Badge variant="outline" className="ml-2 border-emerald-700 text-emerald-400 text-[10px]">当前</Badge>}
</TableCell>
<TableCell className="text-slate-300">{rsReady}/{rsTotal}</TableCell>
<TableCell className="text-xs font-mono text-slate-400 max-w-[240px] truncate">{r.image || '-'}</TableCell>
<TableCell className="text-xs text-slate-400 font-mono">#{r.revision || '-'}</TableCell>
<TableCell>
<Badge style={{ background: `${ok ? STATUS_COLORS.green : STATUS_COLORS.orange}22`, color: ok ? STATUS_COLORS.green : STATUS_COLORS.orange, border: `1px solid ${ok ? STATUS_COLORS.green : STATUS_COLORS.orange}44` }}>
{r.status || (ok ? 'Ready' : 'Updating')}
</Badge>
</TableCell>
<TableCell className="text-xs text-slate-500">{formatK8sTime(r.created_at)}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
// ============ 容器模板表(镜像/端口/资源/探针) ============
function ContainersTable({ deployment }: { deployment: any }) {
const containers: any[] = deployment.containers || deployment.container_specs || deployment.spec_containers || [];
if (containers.length === 0) {
return <p className="text-xs text-slate-500 py-3">无容器详情数据(扁平字段缺少 containers,可查看 YAML)</p>;
}
return (
<div className="space-y-3">
{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 || [];
return (
<div key={idx} className="border border-slate-800 rounded-lg p-3 bg-slate-950/30">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Container className="h-4 w-4 text-blue-400" />
<span className="text-sm font-semibold text-slate-200">{c.name || `container-${idx}`}</span>
</div>
<code className="text-[11px] text-slate-400 font-mono truncate max-w-[400px]">{c.image}</code>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
<div className="flex items-center gap-1.5 text-slate-400">
<Cpu className="h-3.5 w-3.5 text-amber-400" />
<span>CPU: <span className="text-slate-200 font-mono">{cpuReq || '-'}/{cpuLim || '-'}</span></span>
</div>
<div className="flex items-center gap-1.5 text-slate-400">
<MemoryStick className="h-3.5 w-3.5 text-purple-400" />
<span>内存: <span className="text-slate-200 font-mono">{memReq || '-'}/{memLim || '-'}</span></span>
</div>
<div className="flex items-center gap-1.5 text-slate-400">
<Network className="h-3.5 w-3.5 text-cyan-400" />
<span>端口: <span className="text-slate-200 font-mono">{ports.map((p: any) => typeof p === 'object' ? (p.containerPort || p.port) : p).join(', ') || '-'}</span></span>
</div>
<div className="flex items-center gap-1.5 text-slate-400">
<HardDrive className="h-3.5 w-3.5 text-emerald-400" />
<span>挂载: <span className="text-slate-200 font-mono">{(c.volume_mounts || c.volumeMounts || []).length || 0}</span></span>
</div>
</div>
{/* 探针 */}
<div className="mt-2 flex flex-wrap gap-3 text-[11px] text-slate-500">
{c.liveness_probe || c.livenessProbe ? (
<span className="text-emerald-500">● 存活探针</span>
) : <span className="text-slate-700">○ 无存活探针</span>}
{c.readiness_probe || c.readinessProbe ? (
<span className="text-blue-500">● 就绪探针</span>
) : <span className="text-slate-700">○ 无就绪探针</span>}
{c.startup_probe || c.startupProbe ? (
<span className="text-purple-500">● 启动探针</span>
) : null}
</div>
</div>
);
})}
</div>
);
}
// ============ 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 },
];
return (
<div>
<p className="text-xs text-slate-400 mb-2 flex items-center gap-1"><Clock className="h-3 w-3" />状态时间线</p>
<div className="flex items-center gap-1 overflow-x-auto pb-1">
{stages.map((s, i) => {
const prev = stages[i - 1];
const dur = elapsedSeconds(prev?.ts, s.ts);
return (
<div key={s.label} className="flex items-center">
<div className="flex flex-col items-center min-w-[80px]">
<div className="h-2.5 w-2.5 rounded-full" style={{ background: s.ts ? s.color : '#334155' }} />
<span className="text-[10px] mt-1 font-medium" style={{ color: s.ts ? s.color : '#475569' }}>{s.label}</span>
<span className="text-[9px] text-slate-600">{s.ts ? formatK8sTime(s.ts) : '-'}</span>
</div>
{i < stages.length - 1 && (
<div className="flex flex-col items-center mx-1 min-w-[40px]">
<div className="h-0.5 w-10" style={{ background: '#334155' }} />
<span className="text-[9px] text-slate-600 mt-0.5">{dur != null ? formatDuration(dur) : ''}</span>
</div>
)}
</div>
);
})}
</div>
</div>
);
}