fix: K8s 集群添加缺少 code 必填字段

- KubernetesClusterDialog: 新增 code 表单字段
- 名称变更自动生成 code(小写+连字符)
- handleSubmit 增加 code 校验 + toast 提示
- payload 增加 code 字段(后端 binding:required)
This commit is contained in:
attacker committed 2026-06-21 09:50:07 +00:00
1 parent f24fe43ac0
commit ce438c9f05
18 files changed
+3941 -144

No files matched your search

@@ -0,0 +1,395 @@
// 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>
);
}