- KubernetesClusterDialog: 新增 code 表单字段 - 名称变更自动生成 code(小写+连字符) - handleSubmit 增加 code 校验 + toast 提示 - payload 增加 code 字段(后端 binding:required)
396 lines
22 KiB
TypeScript
396 lines
22 KiB
TypeScript
// 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>
|
||
);
|
||
}
|