feat(k8s-ui): 重写 K8s 前端 — Deployment 详情页/HPA/CronJob/操作按钮
api.ts 改造: - 修复 scaleDeployment/restartDeployment/deletePod 死桩 → 真实后端 API - 新增: getPodLogs/execPod/updateDeploymentImage (CI webhook) - 新增: getHPAs/createHPA/deleteHPA (HPA 管理) - 新增: getCronJobs/createCronJob/suspendCronJob/triggerCronJob/deleteCronJob - 新增: getNamespaces DeploymentDetail 新组件: - 副本状态卡片 (Ready/Total/Available) - 副本加减按钮 (scale control) - 滚动重启按钮 - 镜像更新表单 (CI webhook inline) - Pod 列表 (按 deployment 名前缀过滤) - Pod 日志查看器 (tail/container 参数) - HPA 管理选项卡 (列表+删除) - 创建 HPA 弹窗 (CPU/内存指标配置) K8sWorkloads 增强: - Deployment 表添加「操作」列(副本加减 + 查看详情) - 点击 Deployment 行 → 跳转 DeploymentDetail 详情页 - handleQuickScale 快速扩缩容 新增 useToast hook (轻量级 toast 通知)
This commit is contained in:
1 parent
abf2917fad
commit
1a913549fc
4 files changed
+714
-10
No files matched your search
@@ -0,0 +1,570 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
ArrowLeft, Minus, Plus, RotateCw, Upload, FileText, Terminal,
|
||||
Gauge, Clock, Play, Pause, Trash2, RefreshCw, Loader2,
|
||||
CheckCircle, AlertTriangle, XCircle, ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { k8sApi } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
interface DeploymentDetailProps {
|
||||
clusterId: string;
|
||||
namespace: string;
|
||||
deploymentName: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function DeploymentDetail({ clusterId, namespace, deploymentName, onBack }: DeploymentDetailProps) {
|
||||
const { toast } = useToast();
|
||||
const [deployment, setDeployment] = useState<any>(null);
|
||||
const [pods, setPods] = useState<any[]>([]);
|
||||
const [hpas, setHpas] = useState<any[]>([]);
|
||||
const [logs, setLogs] = useState<string>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState('');
|
||||
|
||||
// Scale
|
||||
const [scaleValue, setScaleValue] = useState(0);
|
||||
// Image update
|
||||
const [newImage, setNewImage] = useState('');
|
||||
const [showImageUpdate, setShowImageUpdate] = useState(false);
|
||||
// Logs
|
||||
const [logPod, setLogPod] = useState('');
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
const [logTail, setLogTail] = useState(200);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [depRes, podRes, hpaRes] = await Promise.all([
|
||||
k8sApi.getDeployments(clusterId).catch(() => []),
|
||||
k8sApi.getPods(clusterId).catch(() => []),
|
||||
k8sApi.getHPAs(clusterId).catch(() => ({ items: [] })),
|
||||
]);
|
||||
const toArray = (v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || []));
|
||||
const allDeps = toArray(depRes);
|
||||
const allPods = toArray(podRes);
|
||||
const allHpas = toArray(hpaRes);
|
||||
|
||||
const dep = allDeps.find((d: any) => d.name === deploymentName && d.namespace === namespace);
|
||||
setDeployment(dep || null);
|
||||
if (dep) {
|
||||
setScaleValue(dep.replicas || 0);
|
||||
setNewImage(dep.image || '');
|
||||
}
|
||||
|
||||
// Filter pods by deployment label (prefix match on pod name)
|
||||
const depPods = allPods.filter((p: any) =>
|
||||
p.namespace === namespace && p.name.startsWith(deploymentName)
|
||||
);
|
||||
setPods(depPods);
|
||||
|
||||
// Filter HPAs targeting this deployment
|
||||
const depHpas = allHpas.filter((h: any) => {
|
||||
const ref = h.spec?.scaleTargetRef;
|
||||
return ref?.name === deploymentName && h.metadata?.namespace === namespace;
|
||||
});
|
||||
setHpas(depHpas);
|
||||
} catch (e) {
|
||||
console.error('加载详情失败:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [clusterId, namespace, deploymentName]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const handleScale = async (delta: number) => {
|
||||
const newReplicas = (deployment?.replicas || 0) + delta;
|
||||
if (newReplicas < 0) return;
|
||||
setActionLoading('scale');
|
||||
try {
|
||||
await k8sApi.scaleDeployment(clusterId, namespace, deploymentName, newReplicas);
|
||||
toast({ title: '扩缩容成功', description: `副本数已设置为 ${newReplicas}` });
|
||||
loadData();
|
||||
} catch (e: any) {
|
||||
toast({ title: '操作失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally { setActionLoading(''); }
|
||||
};
|
||||
|
||||
const handleRestart = async () => {
|
||||
setActionLoading('restart');
|
||||
try {
|
||||
await k8sApi.restartDeployment(clusterId, namespace, deploymentName);
|
||||
toast({ title: '重启指令已下发', description: 'Deployment 正在滚动重启' });
|
||||
} catch (e: any) {
|
||||
toast({ title: '重启失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally { setActionLoading(''); }
|
||||
};
|
||||
|
||||
const handleUpdateImage = async () => {
|
||||
if (!newImage.trim()) return;
|
||||
setActionLoading('image');
|
||||
try {
|
||||
await k8sApi.updateDeploymentImage(clusterId, namespace, deploymentName, newImage);
|
||||
toast({ title: '镜像更新成功', description: `正在滚动更新为 ${newImage}` });
|
||||
setShowImageUpdate(false);
|
||||
loadData();
|
||||
} catch (e: any) {
|
||||
toast({ title: '更新失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally { setActionLoading(''); }
|
||||
};
|
||||
|
||||
const handleLoadLogs = async (podName: string) => {
|
||||
setLogPod(podName);
|
||||
setShowLogs(true);
|
||||
setLogs('');
|
||||
try {
|
||||
const res: any = await k8sApi.getPodLogs(clusterId, namespace, podName, logTail);
|
||||
setLogs(res?.data?.logs || res?.logs || '无日志');
|
||||
} catch {
|
||||
setLogs('获取日志失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleHpaToggle = async (hpa: any) => {
|
||||
// Not direct toggle — delete HPA
|
||||
if (!confirm(`确定删除 HPA "${hpa.metadata?.name}"?`)) return;
|
||||
try {
|
||||
await k8sApi.deleteHPA(clusterId, namespace, hpa.metadata?.name);
|
||||
toast({ title: 'HPA 已删除' });
|
||||
loadData();
|
||||
} catch (e: any) {
|
||||
toast({ title: '删除失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-12 text-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin mx-auto text-slate-500" />
|
||||
<p className="text-slate-500 mt-3">加载中...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!deployment) {
|
||||
return (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-12 text-center">
|
||||
<XCircle className="h-8 w-8 mx-auto text-red-400" />
|
||||
<p className="text-slate-400 mt-3">未找到 Deployment: {deploymentName}</p>
|
||||
<Button variant="outline" className="mt-4" onClick={onBack}>返回</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const ready = deployment.ready_replicas || deployment.readyReplicas || 0;
|
||||
const total = deployment.replicas || 0;
|
||||
const available = deployment.available_replicas || deployment.availableReplicas || 0;
|
||||
const isHealthy = ready === total;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" className="text-slate-400" onClick={onBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />返回
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-bold text-slate-100">{deploymentName}</h1>
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">{namespace}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mt-0.5">镜像: {deployment.image || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-400" onClick={loadData}>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-3">
|
||||
<p className="text-xs text-slate-500">副本状态</p>
|
||||
<p className={`text-lg font-bold ${isHealthy ? 'text-emerald-400' : 'text-amber-400'}`}>
|
||||
{ready}/{total}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-3">
|
||||
<p className="text-xs text-slate-500">可用副本</p>
|
||||
<p className="text-lg font-bold text-blue-400">{available}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-3">
|
||||
<p className="text-xs text-slate-500">镜像</p>
|
||||
<p className="text-sm font-mono text-slate-300 truncate max-w-[200px]" title={deployment.image}>
|
||||
{deployment.image?.split('/').pop() || '-'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-3">
|
||||
<p className="text-xs text-slate-500">HPA</p>
|
||||
<p className="text-lg font-bold text-purple-400">{hpas.length > 0 ? '已启用' : '未配置'}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base text-slate-200">操作</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Scale controls */}
|
||||
<div className="flex items-center gap-1 bg-slate-800 rounded-lg p-1">
|
||||
<Button
|
||||
variant="ghost" size="icon" className="h-8 w-8 text-slate-300 hover:text-slate-100"
|
||||
disabled={actionLoading === 'scale' || total <= 0}
|
||||
onClick={() => handleScale(-1)}
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm font-mono text-slate-200 w-8 text-center">
|
||||
{actionLoading === 'scale' ? <Loader2 className="h-4 w-4 animate-spin inline" /> : total}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost" size="icon" className="h-8 w-8 text-slate-300 hover:text-slate-100"
|
||||
disabled={actionLoading === 'scale'}
|
||||
onClick={() => handleScale(1)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline" size="sm" className="border-slate-700 text-slate-300 hover:text-slate-100"
|
||||
disabled={actionLoading === 'restart'}
|
||||
onClick={handleRestart}
|
||||
>
|
||||
{actionLoading === 'restart' ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <RotateCw className="h-4 w-4 mr-1" />}
|
||||
滚动重启
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline" size="sm" className="border-slate-700 text-slate-300 hover:text-slate-100"
|
||||
onClick={() => setShowImageUpdate(!showImageUpdate)}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-1" />
|
||||
更新镜像
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Inline image update */}
|
||||
{showImageUpdate && (
|
||||
<div className="mt-3 flex items-center gap-2 p-3 bg-slate-800/50 rounded-lg border border-slate-700">
|
||||
<Input
|
||||
value={newImage}
|
||||
onChange={(e) => setNewImage(e.target.value)}
|
||||
placeholder="输入新镜像地址,如 harbor.example.com/app:v2.0"
|
||||
className="flex-1 h-9 bg-slate-900 border-slate-700 text-slate-200 text-sm font-mono"
|
||||
/>
|
||||
<Button size="sm" className="bg-blue-600 hover:bg-blue-700"
|
||||
disabled={actionLoading === 'image' || !newImage.trim()}
|
||||
onClick={handleUpdateImage}
|
||||
>
|
||||
{actionLoading === 'image' ? <Loader2 className="h-4 w-4 animate-spin" /> : '更新'}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="text-slate-400" onClick={() => setShowImageUpdate(false)}>取消</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tabs: Pods / Logs / HPA */}
|
||||
<Tabs defaultValue="pods">
|
||||
<TabsList className="bg-slate-800 border border-slate-700">
|
||||
<TabsTrigger value="pods" className="data-[state=active]:bg-slate-700">
|
||||
Pod 列表 ({pods.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="logs" className="data-[state=active]:bg-slate-700">
|
||||
<FileText className="h-4 w-4 mr-1" />日志 {showLogs && `(${logPod})`}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="hpa" className="data-[state=active]:bg-slate-700">
|
||||
<Gauge className="h-4 w-4 mr-1" />HPA ({hpas.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Pods Tab */}
|
||||
<TabsContent value="pods">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">Pod 名称</TableHead>
|
||||
<TableHead className="text-slate-400">状态</TableHead>
|
||||
<TableHead className="text-slate-400">节点</TableHead>
|
||||
<TableHead className="text-slate-400">IP</TableHead>
|
||||
<TableHead className="text-slate-400">重启</TableHead>
|
||||
<TableHead className="text-slate-400">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pods.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-slate-500 py-8">
|
||||
暂无 Pod
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
pods.map((pod: any) => (
|
||||
<TableRow key={pod.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200">{pod.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={
|
||||
pod.status === 'Running' ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' :
|
||||
pod.status === 'Pending' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
|
||||
'bg-red-500/20 text-red-400 border-red-500/30'
|
||||
}>{pod.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{pod.node_name || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-slate-500 font-mono">{pod.ip || '-'}</TableCell>
|
||||
<TableCell className={pod.restart_count > 0 ? 'text-yellow-400' : 'text-slate-500'}>
|
||||
{pod.restart_count || 0}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400"
|
||||
title="查看日志" onClick={() => handleLoadLogs(pod.name)}>
|
||||
<FileText 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={() => toast({ title: '终端功能开发中', description: '需要 SPDY/WebSocket 支持' })}>
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Logs Tab */}
|
||||
<TabsContent value="logs">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm text-slate-400">Pod 日志</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500">最近</span>
|
||||
<Input
|
||||
type="number" value={logTail} onChange={(e) => setLogTail(Number(e.target.value))}
|
||||
className="w-20 h-7 bg-slate-800 border-slate-700 text-slate-200 text-xs"
|
||||
/>
|
||||
<span className="text-xs text-slate-500">行</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!showLogs ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<FileText className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>点击 Pod 列表中的「日志」按钮查看</p>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="bg-slate-950 rounded-lg p-4 text-xs text-slate-300 font-mono overflow-auto max-h-[500px] whitespace-pre-wrap">
|
||||
{logs || '无日志内容'}
|
||||
</pre>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* HPA Tab */}
|
||||
<TabsContent value="hpa">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm text-slate-400">HPA 自动扩缩容</CardTitle>
|
||||
<CreateHPADialog
|
||||
clusterId={clusterId}
|
||||
namespace={namespace}
|
||||
deploymentName={deploymentName}
|
||||
onCreated={loadData}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{hpas.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<Gauge className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>未配置 HPA</p>
|
||||
<p className="text-xs mt-1">点击右上角「创建 HPA」设置自动扩缩容</p>
|
||||
</div>
|
||||
) : (
|
||||
<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">当前/目标</TableHead>
|
||||
<TableHead className="text-slate-400">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{hpas.map((hpa: any) => {
|
||||
const spec = hpa.spec || {};
|
||||
const status = hpa.status || {};
|
||||
const metrics = (spec.metrics || []).map((m: any) => {
|
||||
if (m.resource) return `${m.resource.name}: ${m.resource.target.averageUtilization}%`;
|
||||
return m.type;
|
||||
}).join(', ');
|
||||
return (
|
||||
<TableRow key={hpa.metadata?.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200">{hpa.metadata?.name}</TableCell>
|
||||
<TableCell className="text-slate-300">{spec.minReplicas} / {spec.maxReplicas}</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{metrics || '-'}</TableCell>
|
||||
<TableCell className="text-slate-300">{status.currentReplicas || 0} / {status.desiredReplicas || 0}</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300"
|
||||
onClick={() => handleHpaToggle(hpa)} title="删除 HPA">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Create HPA Dialog (inline)
|
||||
// ============================================================================
|
||||
function CreateHPADialog({ clusterId, namespace, deploymentName, onCreated }: {
|
||||
clusterId: string; namespace: string; deploymentName: string; onCreated: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
minReplicas: 1,
|
||||
maxReplicas: 10,
|
||||
cpuPercent: 80,
|
||||
memPercent: 0,
|
||||
});
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!form.name.trim()) {
|
||||
toast({ title: '名称不能为空', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await k8sApi.createHPA(clusterId, {
|
||||
name: form.name,
|
||||
namespace,
|
||||
targetKind: 'Deployment',
|
||||
targetName: deploymentName,
|
||||
minReplicas: form.minReplicas,
|
||||
maxReplicas: form.maxReplicas,
|
||||
cpuPercent: form.cpuPercent,
|
||||
memPercent: form.memPercent,
|
||||
});
|
||||
toast({ title: 'HPA 创建成功' });
|
||||
setOpen(false);
|
||||
onCreated();
|
||||
setForm({ name: '', minReplicas: 1, maxReplicas: 10, cpuPercent: 80, memPercent: 0 });
|
||||
} catch (e: any) {
|
||||
toast({ title: '创建失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300" onClick={() => setOpen(true)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />创建 HPA
|
||||
</Button>
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setOpen(false)}>
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-xl p-6 w-[480px] max-h-[80vh] overflow-auto" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-bold text-slate-100 mb-4">创建 HPA</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-slate-400 text-sm">名称</Label>
|
||||
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder={`hpa-${deploymentName}`}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-400 text-sm">目标</Label>
|
||||
<p className="text-slate-300 text-sm mt-1 bg-slate-800 rounded px-3 py-2">
|
||||
Deployment: {deploymentName} · 命名空间: {namespace}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-slate-400 text-sm">最小副本数</Label>
|
||||
<Input type="number" value={form.minReplicas} min={1}
|
||||
onChange={(e) => setForm({ ...form, minReplicas: Number(e.target.value) })}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-400 text-sm">最大副本数</Label>
|
||||
<Input type="number" value={form.maxReplicas} min={1}
|
||||
onChange={(e) => setForm({ ...form, maxReplicas: Number(e.target.value) })}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-slate-400 text-sm">CPU 目标 (%)</Label>
|
||||
<Input type="number" value={form.cpuPercent} min={0} max={100}
|
||||
onChange={(e) => setForm({ ...form, cpuPercent: Number(e.target.value) })}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-400 text-sm">内存目标 (%) <span className="text-slate-600">可选</span></Label>
|
||||
<Input type="number" value={form.memPercent} min={0} max={100}
|
||||
onChange={(e) => setForm({ ...form, memPercent: Number(e.target.value) })}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={() => setOpen(false)}>取消</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" disabled={loading} onClick={handleCreate}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : '创建'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,10 +10,16 @@ import {
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { RefreshCw, Search, Boxes, Loader2, Container } from 'lucide-react';
|
||||
import {
|
||||
RefreshCw, Search, Boxes, Loader2, Container,
|
||||
Plus, Minus, RotateCw, Upload, FileText, Eye,
|
||||
} from 'lucide-react';
|
||||
import { k8sApi } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { DeploymentDetail } from './DeploymentDetail';
|
||||
|
||||
export function K8sWorkloads() {
|
||||
const { toast } = useToast();
|
||||
const [clusters, setClusters] = useState<any[]>([]);
|
||||
const [selectedCluster, setSelectedCluster] = useState('');
|
||||
const [deployments, setDeployments] = useState<any[]>([]);
|
||||
@@ -21,6 +27,10 @@ export function K8sWorkloads() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('deployments');
|
||||
const [actionLoading, setActionLoading] = useState<Record<string, string>>({});
|
||||
|
||||
// Detail view
|
||||
const [selectedDeployment, setSelectedDeployment] = useState<{ name: string; namespace: string } | null>(null);
|
||||
|
||||
const loadClusters = useCallback(async () => {
|
||||
try {
|
||||
@@ -66,6 +76,34 @@ export function K8sWorkloads() {
|
||||
return <Badge className={map[status] || 'bg-slate-500/20 text-slate-400 border-slate-500/30'}>{status || 'Unknown'}</Badge>;
|
||||
};
|
||||
|
||||
const handleQuickScale = async (dep: any, delta: number) => {
|
||||
const key = `scale-${dep.name}`;
|
||||
setActionLoading((prev) => ({ ...prev, [key]: 'loading' }));
|
||||
try {
|
||||
const newReplicas = (dep.replicas || 0) + delta;
|
||||
if (newReplicas < 0) return;
|
||||
await k8sApi.scaleDeployment(selectedCluster, dep.namespace, dep.name, newReplicas);
|
||||
toast({ title: '扩缩容成功', description: `${dep.name} → ${newReplicas} 副本` });
|
||||
loadData();
|
||||
} catch (e: any) {
|
||||
toast({ title: '操作失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally {
|
||||
setActionLoading((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||
}
|
||||
};
|
||||
|
||||
// Show detail view if a deployment is selected
|
||||
if (selectedDeployment) {
|
||||
return (
|
||||
<DeploymentDetail
|
||||
clusterId={selectedCluster}
|
||||
namespace={selectedDeployment.namespace}
|
||||
deploymentName={selectedDeployment.name}
|
||||
onBack={() => { setSelectedDeployment(null); loadData(); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -132,7 +170,7 @@ export function K8sWorkloads() {
|
||||
<TableHead className="text-slate-400">命名空间</TableHead>
|
||||
<TableHead className="text-slate-400">副本</TableHead>
|
||||
<TableHead className="text-slate-400">镜像</TableHead>
|
||||
<TableHead className="text-slate-400">策略</TableHead>
|
||||
<TableHead className="text-slate-400 w-[140px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -142,7 +180,8 @@ export function K8sWorkloads() {
|
||||
<TableRow><TableCell colSpan={5} className="text-center text-slate-500 py-12"><Boxes className="h-8 w-8 mx-auto mb-2 opacity-50" />暂无 Deployment</TableCell></TableRow>
|
||||
) : (
|
||||
filteredDeps.map((d) => (
|
||||
<TableRow key={d.id} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableRow key={d.id || d.name} className="border-slate-800 hover:bg-slate-800/30 cursor-pointer"
|
||||
onClick={() => setSelectedDeployment({ name: d.name, namespace: d.namespace })}>
|
||||
<TableCell className="font-medium text-slate-200">{d.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{d.namespace}</Badge></TableCell>
|
||||
<TableCell>
|
||||
@@ -162,7 +201,23 @@ export function K8sWorkloads() {
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">{d.image || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{d.strategy || 'RollingUpdate'}</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-slate-200"
|
||||
title="减少副本" disabled={(d.replicas || 0) <= 0}
|
||||
onClick={() => handleQuickScale(d, -1)}>
|
||||
<Minus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-slate-200"
|
||||
title="增加副本" onClick={() => handleQuickScale(d, 1)}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400"
|
||||
title="查看详情" onClick={() => setSelectedDeployment({ name: d.name, namespace: d.namespace })}>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
variant?: 'default' | 'destructive';
|
||||
}
|
||||
|
||||
interface UseToastReturn {
|
||||
toast: (opts: { title: string; description?: string; variant?: 'default' | 'destructive' }) => void;
|
||||
toasts: Toast[];
|
||||
dismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
export function useToast(): UseToastReturn {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const toast = useCallback((opts: { title: string; description?: string; variant?: 'default' | 'destructive' }) => {
|
||||
const id = Math.random().toString(36).slice(2);
|
||||
const t: Toast = { id, ...opts };
|
||||
setToasts((prev) => [...prev, t]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((x) => x.id !== id));
|
||||
}, 4000);
|
||||
}, []);
|
||||
|
||||
const dismiss = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
return { toast, toasts, dismiss };
|
||||
}
|
||||
@@ -196,14 +196,60 @@ export const k8sApi = {
|
||||
saveTopology: async (clusterId: number | string, layoutData: string) => { // Logan.Li
|
||||
return http.put(`/k8s-clusters/${clusterId}/topology`, { layout_data: layoutData });
|
||||
},
|
||||
scaleDeployment: async (_clusterId: string, _namespace: string, _name: string, _replicas: number) => {
|
||||
return { success: true };
|
||||
|
||||
// === 工作负载操作 (Logan.Li) ===
|
||||
getPodLogs: async (clusterId: number | string, namespace: string, podName: string, tail: number = 200, container?: string) => {
|
||||
const params = new URLSearchParams({ tail: String(tail) });
|
||||
if (container) params.set('container', container);
|
||||
return http.get(`/k8s-clusters/${clusterId}/namespaces/${namespace}/pods/${podName}/logs?${params}`);
|
||||
},
|
||||
restartDeployment: async (_clusterId: string, _namespace: string, _name: string) => {
|
||||
return { success: true };
|
||||
execPod: async (clusterId: number | string, namespace: string, podName: string, command: string[], container?: string) => {
|
||||
return http.post(`/k8s-clusters/${clusterId}/namespaces/${namespace}/pods/${podName}/exec`, { command, container });
|
||||
},
|
||||
deletePod: async (_clusterId: string, _namespace: string, _name: string) => {
|
||||
return { success: true };
|
||||
scaleDeployment: async (clusterId: number | string, namespace: string, name: string, replicas: number) => {
|
||||
return http.post(`/k8s-clusters/${clusterId}/namespaces/${namespace}/deployments/${name}/scale`, { replicas });
|
||||
},
|
||||
restartDeployment: async (clusterId: number | string, namespace: string, name: string) => {
|
||||
return http.post(`/k8s-clusters/${clusterId}/namespaces/${namespace}/deployments/${name}/restart`);
|
||||
},
|
||||
updateDeploymentImage: async (clusterId: number | string, namespace: string, name: string, image: string, containerName?: string) => {
|
||||
return http.post(`/k8s-clusters/${clusterId}/namespaces/${namespace}/deployments/${name}/update-image`, { image, container_name: containerName });
|
||||
},
|
||||
deletePod: async (clusterId: number | string, namespace: string, name: string) => {
|
||||
return http.delete(`/k8s-clusters/${clusterId}/namespaces/${namespace}/pods/${name}`);
|
||||
},
|
||||
|
||||
// === HPA 管理 (Logan.Li) ===
|
||||
getHPAs: async (clusterId: number | string) => {
|
||||
return http.get(`/k8s-clusters/${clusterId}/hpas`);
|
||||
},
|
||||
createHPA: async (clusterId: number | string, data: { name: string; namespace: string; targetKind: string; targetName: string; minReplicas: number; maxReplicas: number; cpuPercent: number; memPercent: number }) => {
|
||||
return http.post(`/k8s-clusters/${clusterId}/hpas`, data);
|
||||
},
|
||||
deleteHPA: async (clusterId: number | string, namespace: string, name: string) => {
|
||||
return http.delete(`/k8s-clusters/${clusterId}/namespaces/${namespace}/hpas/${name}`);
|
||||
},
|
||||
|
||||
// === CronJob 管理 (Logan.Li) ===
|
||||
getCronJobs: async (clusterId: number | string) => {
|
||||
return http.get(`/k8s-clusters/${clusterId}/cronjobs`);
|
||||
},
|
||||
createCronJob: async (clusterId: number | string, data: { name: string; namespace: string; schedule: string; image: string; command: string[] }) => {
|
||||
return http.post(`/k8s-clusters/${clusterId}/cronjobs`, data);
|
||||
},
|
||||
suspendCronJob: async (clusterId: number | string, namespace: string, name: string, suspend: boolean) => {
|
||||
return http.post(`/k8s-clusters/${clusterId}/namespaces/${namespace}/cronjobs/${name}/suspend`, { suspend });
|
||||
},
|
||||
triggerCronJob: async (clusterId: number | string, namespace: string, name: string) => {
|
||||
return http.post(`/k8s-clusters/${clusterId}/namespaces/${namespace}/cronjobs/${name}/trigger`);
|
||||
},
|
||||
deleteCronJob: async (clusterId: number | string, namespace: string, name: string) => {
|
||||
return http.delete(`/k8s-clusters/${clusterId}/namespaces/${namespace}/cronjobs/${name}`);
|
||||
},
|
||||
|
||||
// === Namespace (Logan.Li) ===
|
||||
getNamespaces: async (clusterId: number | string) => {
|
||||
return http.get(`/k8s-clusters/${clusterId}/namespaces`);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user