fix: K8s 集群添加缺少 code 必填字段
- KubernetesClusterDialog: 新增 code 表单字段 - 名称变更自动生成 code(小写+连字符) - handleSubmit 增加 code 校验 + toast 提示 - payload 增加 code 字段(后端 binding:required)
This commit is contained in:
1 parent
f24fe43ac0
commit
ce438c9f05
18 files changed
+3941
-144
No files matched your search
@@ -0,0 +1,569 @@
|
||||
// DeploymentWorkload.tsx — Kuboard 风格 Deployment 详情主组件
|
||||
// - 顶部操作菜单: 编辑 | 预览/编辑YAML | 调整镜像版本 | 伸缩 | 日志/终端 | 重启 | 删除 | 元数据 | 运行时 | 服务 | 应用路由
|
||||
// - 双模式: view(只读增强看板)/ edit(表单编辑器)
|
||||
// 作者: Logan <admin@attacker.club>
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
ArrowLeft, Pencil, FileCode, Image as ImageIcon, Minus, Plus, RotateCw,
|
||||
Trash2, FileText, Terminal, RefreshCw, Loader2, X,
|
||||
ChevronDown, Cpu, Network, GitBranch, Info,
|
||||
CheckCircle, AlertTriangle, XCircle,
|
||||
} from 'lucide-react';
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useUserStore } from '@/store';
|
||||
import { YamlViewer } from './YamlViewer';
|
||||
import { DeploymentView } from './DeploymentView';
|
||||
import { DeploymentEdit } from './DeploymentEdit';
|
||||
|
||||
export interface DeploymentWorkloadProps {
|
||||
clusterCode: string;
|
||||
namespace: string;
|
||||
deploymentName: string;
|
||||
/** 'view' | 'edit' — 来自路由参数 */
|
||||
mode: 'view' | 'edit';
|
||||
onBack: () => void;
|
||||
/** 跳转到另一模式(用于"编辑"按钮、保存成功后切换) */
|
||||
onModeChange: (mode: 'view' | 'edit') => void;
|
||||
}
|
||||
|
||||
type ActionModal =
|
||||
| { kind: 'yaml' }
|
||||
| { kind: 'image' }
|
||||
| { kind: 'scale' }
|
||||
| { kind: 'podLogs'; pod: string }
|
||||
| { kind: 'podYaml'; pod: string }
|
||||
| { kind: 'delete' }
|
||||
| null;
|
||||
|
||||
export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mode, onBack, onModeChange }: DeploymentWorkloadProps) {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [deployment, setDeployment] = useState<any>(null);
|
||||
const [manifest, setManifest] = useState<any>(null);
|
||||
const [pods, setPods] = useState<any[]>([]);
|
||||
const [replicaSets, setReplicaSets] = useState<any[]>([]);
|
||||
const [revisions, setRevisions] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState('');
|
||||
const [modal, setModal] = useState<ActionModal>(null);
|
||||
// 调整镜像版本
|
||||
const [newImage, setNewImage] = useState('');
|
||||
// 伸缩
|
||||
const [scaleValue, setScaleValue] = useState(0);
|
||||
|
||||
const toArray = (v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || []));
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [dep, podRes, rsRes, revRes] = await Promise.all([
|
||||
kubernetesApi.getDeployment(clusterCode, namespace, deploymentName).catch(() => null),
|
||||
kubernetesApi.getNamespacePods(clusterCode, namespace).catch(() => []),
|
||||
kubernetesApi.getReplicaSets(clusterCode, namespace).catch(() => []),
|
||||
kubernetesApi.getDeploymentRevisions(clusterCode, namespace, deploymentName).catch(() => []),
|
||||
]);
|
||||
const depData = dep || (Array.isArray(dep) ? dep[0] : null);
|
||||
setDeployment(depData);
|
||||
setScaleValue(depData?.replicas || 0);
|
||||
setNewImage(depData?.image || '');
|
||||
|
||||
const allPods = toArray(podRes);
|
||||
// 过滤当前 Deployment 的 Pod(按名称前缀,兼容 ownerReference 不可用场景)
|
||||
const depPods = allPods.filter((p: any) =>
|
||||
p.namespace === namespace && (p.name || '').startsWith(deploymentName)
|
||||
);
|
||||
setPods(depPods);
|
||||
|
||||
const allRs = toArray(rsRes);
|
||||
const depRs = allRs.filter((rs: any) =>
|
||||
(rs.namespace === namespace) &&
|
||||
((rs.owner_deployment === deploymentName) || (rs.name || '').startsWith(deploymentName))
|
||||
);
|
||||
setReplicaSets(depRs);
|
||||
|
||||
setRevisions(toArray(revRes));
|
||||
} catch (e) {
|
||||
console.error('加载详情失败:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [clusterCode, namespace, deploymentName]);
|
||||
|
||||
// 加载 manifest(用于 edit 模式 + YAML 编辑)
|
||||
const loadManifest = useCallback(async () => {
|
||||
try {
|
||||
const yamlText = await kubernetesApi.getResourceYaml(clusterCode, namespace, 'deployments', deploymentName);
|
||||
// 简易 YAML→JSON:交给浏览器原生 JSON.parse 兜底(K8s API 多以 JSON 返回);
|
||||
// 若是纯 YAML,则尝试用 js-yaml(如项目已安装),否则保留为 null 让编辑模式降级。
|
||||
let parsed: any = null;
|
||||
try { parsed = JSON.parse(yamlText); } catch {
|
||||
// 非纯 JSON,尝试动态加载 js-yaml
|
||||
try {
|
||||
const jsyaml: any = await import('js-yaml');
|
||||
parsed = jsyaml.load(yamlText);
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
}
|
||||
setManifest(parsed || deployment);
|
||||
} catch (e) {
|
||||
console.warn('加载 manifest 失败(编辑功能降级):', e);
|
||||
setManifest(deployment);
|
||||
}
|
||||
}, [clusterCode, namespace, deploymentName, deployment]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
useEffect(() => { if (mode === 'edit' && !manifest) loadManifest(); }, [mode, manifest, loadManifest]);
|
||||
|
||||
// ============ 操作 ============
|
||||
const handleScale = async (delta: number) => {
|
||||
const newReplicas = (deployment?.replicas || 0) + delta;
|
||||
if (newReplicas < 0) return;
|
||||
setActionLoading('scale');
|
||||
try {
|
||||
await kubernetesApi.scaleDeployment(clusterCode, namespace, deploymentName, newReplicas);
|
||||
toast({ title: '扩缩容成功', description: `副本数已设置为 ${newReplicas}` });
|
||||
loadData();
|
||||
} catch (e: any) {
|
||||
toast({ title: '操作失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally { setActionLoading(''); }
|
||||
};
|
||||
|
||||
const handleScaleTo = async () => {
|
||||
setActionLoading('scale');
|
||||
try {
|
||||
await kubernetesApi.scaleDeployment(clusterCode, namespace, deploymentName, scaleValue);
|
||||
toast({ title: '伸缩成功', description: `副本数已设置为 ${scaleValue}` });
|
||||
setModal(null);
|
||||
loadData();
|
||||
} catch (e: any) {
|
||||
toast({ title: '操作失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally { setActionLoading(''); }
|
||||
};
|
||||
|
||||
const handleRestart = async () => {
|
||||
if (!confirm(`确认滚动重启 Deployment "${deploymentName}"?`)) return;
|
||||
setActionLoading('restart');
|
||||
try {
|
||||
await kubernetesApi.restartDeployment(clusterCode, namespace, deploymentName);
|
||||
toast({ title: '重启指令已下发', description: 'Deployment 正在滚动重启' });
|
||||
setTimeout(loadData, 1500);
|
||||
} 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 kubernetesApi.updateDeploymentImage(clusterCode, namespace, deploymentName, newImage);
|
||||
toast({ title: '镜像更新成功', description: `正在滚动更新为 ${newImage}` });
|
||||
setModal(null);
|
||||
loadData();
|
||||
} catch (e: any) {
|
||||
toast({ title: '更新失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally { setActionLoading(''); }
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setActionLoading('delete');
|
||||
try {
|
||||
// 复用通用删除:通过 PUT 空文档或调用隐式端点。若无专用端点,则提示。
|
||||
const tk = useUserStore.getState().token || '';
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
|
||||
const url = `${baseUrl}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${deploymentName}`;
|
||||
const resp = await fetch(url, { method: 'DELETE', headers: { Authorization: `Bearer ${tk}` } });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
toast({ title: '删除成功', description: `${deploymentName} 已删除` });
|
||||
setModal(null);
|
||||
setTimeout(() => navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments`), 800);
|
||||
} catch (e: any) {
|
||||
toast({ title: '删除失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally { setActionLoading(''); }
|
||||
};
|
||||
|
||||
// 日志流(Pod 实时日志)
|
||||
const handleShowPodLogs = (pod: any) => setModal({ kind: 'podLogs', pod: pod.name });
|
||||
const handleShowPodYaml = (pod: any) => setModal({ kind: 'podYaml', pod: pod.name });
|
||||
const handleShowPodTerminal = (_pod: any) => {
|
||||
toast({ title: '容器终端', description: 'Pod exec 终端复用 KubectlTerminal 通道,开发中' });
|
||||
};
|
||||
|
||||
// ============ 渲染 ============
|
||||
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 isHealthy = ready === total && total > 0;
|
||||
const viewBase = `/kubernetes/${clusterCode}/namespace/${namespace}/workload`;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ============ 顶部:标题 + 模式标识 + 操作菜单 ============ */}
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Button variant="ghost" size="sm" className="text-slate-400" onClick={onBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />返回
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="text-xl font-bold text-slate-100 truncate">{deploymentName}</h1>
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">{namespace}</Badge>
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">Deployment</Badge>
|
||||
<Badge
|
||||
className={isHealthy
|
||||
? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30'
|
||||
: 'bg-amber-500/20 text-amber-400 border-amber-500/30'}
|
||||
>
|
||||
{isHealthy ? <CheckCircle className="h-3 w-3 mr-1" /> : <AlertTriangle className="h-3 w-3 mr-1" />}
|
||||
{ready}/{total}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-0.5 truncate">
|
||||
镜像: <code className="text-slate-400">{deployment.image || '-'}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 模式切换 Tab */}
|
||||
<div className="inline-flex rounded-md border border-slate-700 overflow-hidden">
|
||||
<button
|
||||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${mode === 'view' ? 'bg-slate-700 text-slate-100' : 'bg-transparent text-slate-400 hover:text-slate-200'}`}
|
||||
onClick={() => onModeChange('view')}
|
||||
>
|
||||
查看模式
|
||||
</button>
|
||||
<button
|
||||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${mode === 'edit' ? 'bg-blue-600 text-white' : 'bg-transparent text-slate-400 hover:text-slate-200'}`}
|
||||
onClick={() => onModeChange('edit')}
|
||||
>
|
||||
<Pencil className="h-3 w-3 inline mr-1" />编辑模式
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-400" onClick={loadData} title="刷新">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* 顶部操作菜单(Kuboard 风格下拉) */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-200">
|
||||
操作 <ChevronDown className="h-3.5 w-3.5 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52 bg-slate-900 border-slate-700">
|
||||
<DropdownMenuItem className="text-slate-200 hover:bg-slate-800 cursor-pointer" onClick={() => onModeChange('edit')}>
|
||||
<Pencil className="h-4 w-4 mr-2 text-blue-400" /> 编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-200 hover:bg-slate-800 cursor-pointer" onClick={() => setModal({ kind: 'yaml' })}>
|
||||
<FileCode className="h-4 w-4 mr-2 text-emerald-400" /> 预览 / 编辑 YAML
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-200 hover:bg-slate-800 cursor-pointer" onClick={() => setModal({ kind: 'image' })}>
|
||||
<ImageIcon className="h-4 w-4 mr-2 text-purple-400" /> 调整镜像版本
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-200 hover:bg-slate-800 cursor-pointer" onClick={() => { setScaleValue(deployment.replicas || 0); setModal({ kind: 'scale' }); }}>
|
||||
<Minus className="h-4 w-4 mr-2 text-amber-400" /> 伸缩
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-200 hover:bg-slate-800 cursor-pointer" onClick={() => toast({ title: '日志 / 终端', description: '请在 Pod 列表中点击对应按钮' })}>
|
||||
<Terminal className="h-4 w-4 mr-2 text-cyan-400" /> 日志 / 终端
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-200 hover:bg-slate-800 cursor-pointer" onClick={handleRestart}>
|
||||
<RotateCw className="h-4 w-4 mr-2 text-blue-400" /> 重启
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-red-400 hover:bg-slate-800 cursor-pointer" onClick={() => setModal({ kind: 'delete' })}>
|
||||
<Trash2 className="h-4 w-4 mr-2" /> 删除
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="bg-slate-800" />
|
||||
<DropdownMenuItem className="text-slate-300 hover:bg-slate-800 cursor-pointer" onClick={() => toast({ title: '元数据', description: 'Labels / Annotations / Selectors,集成在 YAML 视图中' })}>
|
||||
<Info className="h-4 w-4 mr-2 text-slate-400" /> 元数据
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-300 hover:bg-slate-800 cursor-pointer" onClick={() => toast({ title: '运行时', description: 'Pod 运行状态请见 Pod 实例区' })}>
|
||||
<Cpu className="h-4 w-4 mr-2 text-slate-400" /> 运行时
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-300 hover:bg-slate-800 cursor-pointer"
|
||||
onClick={() => navigate(`${viewBase}/view/Service?selector=${encodeURIComponent(JSON.stringify(deployment.labels || {}))}`)}>
|
||||
<Network className="h-4 w-4 mr-2 text-slate-400" /> 服务
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-300 hover:bg-slate-800 cursor-pointer"
|
||||
onClick={() => navigate(`${viewBase}/view/Ingress?namespace=${namespace}`)}>
|
||||
<GitBranch className="h-4 w-4 mr-2 text-slate-400" /> 应用路由
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ============ 主体:view / edit ============ */}
|
||||
{mode === 'view' ? (
|
||||
<DeploymentView
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
deployment={deployment}
|
||||
pods={pods}
|
||||
replicaSets={replicaSets}
|
||||
revisions={revisions}
|
||||
onRefresh={loadData}
|
||||
onShowPodLogs={handleShowPodLogs}
|
||||
onShowPodTerminal={handleShowPodTerminal}
|
||||
onShowPodYaml={handleShowPodYaml}
|
||||
/>
|
||||
) : (
|
||||
<DeploymentEdit
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
deploymentName={deploymentName}
|
||||
manifest={manifest}
|
||||
onSaved={() => { loadManifest(); loadData(); onModeChange('view'); }}
|
||||
onCancel={() => onModeChange('view')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ============ 弹窗:YAML ============ */}
|
||||
{modal?.kind === 'yaml' && (
|
||||
<YamlViewer
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
resource="deployments"
|
||||
name={deploymentName}
|
||||
cleanBeforeEdit
|
||||
onClose={() => setModal(null)}
|
||||
onSaved={loadData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 弹窗:Pod YAML */}
|
||||
{modal?.kind === 'podYaml' && (
|
||||
<YamlViewer
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
resource="pods"
|
||||
name={modal.pod}
|
||||
cleanBeforeEdit
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ============ 弹窗:调整镜像版本 ============ */}
|
||||
{modal?.kind === 'image' && (
|
||||
<ModalShell title="调整镜像版本" onClose={() => setModal(null)}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-slate-400 text-sm">当前镜像</Label>
|
||||
<p className="text-xs text-slate-500 font-mono mt-1 bg-slate-800 rounded px-3 py-2">{deployment.image || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-400 text-sm">新镜像地址</Label>
|
||||
<Input value={newImage} onChange={e => setNewImage(e.target.value)}
|
||||
placeholder="harbor.example.com/app:v2.0"
|
||||
className="mt-1 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={() => setModal(null)}>取消</Button>
|
||||
<Button 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>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
)}
|
||||
|
||||
{/* ============ 弹窗:伸缩 ============ */}
|
||||
{modal?.kind === 'scale' && (
|
||||
<ModalShell title="伸缩 (Scale)" onClose={() => setModal(null)}>
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-slate-500">当前副本: {deployment.replicas || 0}</p>
|
||||
<div className="flex items-center justify-center gap-3 py-3">
|
||||
<Button variant="outline" size="icon" className="border-slate-700 text-slate-300"
|
||||
disabled={actionLoading === 'scale' || scaleValue <= 0}
|
||||
onClick={() => setScaleValue(v => Math.max(0, v - 1))}>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Input type="number" min={0} value={scaleValue}
|
||||
onChange={e => setScaleValue(Math.max(0, Number(e.target.value)))}
|
||||
className="w-24 text-center text-lg font-bold bg-slate-800 border-slate-700 text-slate-200" />
|
||||
<Button variant="outline" size="icon" className="border-slate-700 text-slate-300"
|
||||
disabled={actionLoading === 'scale'}
|
||||
onClick={() => setScaleValue(v => v + 1)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={() => setModal(null)}>取消</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" disabled={actionLoading === 'scale'} onClick={handleScaleTo}>
|
||||
{actionLoading === 'scale' ? <Loader2 className="h-4 w-4 animate-spin" /> : '确认'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
)}
|
||||
|
||||
{/* ============ 弹窗:删除 ============ */}
|
||||
{modal?.kind === 'delete' && (
|
||||
<ModalShell title="删除 Deployment" onClose={() => setModal(null)}>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-red-400">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
<span className="font-semibold">此操作不可撤销</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400">
|
||||
确认删除 Deployment <code className="text-slate-200 font-mono">{namespace}/{deploymentName}</code>?
|
||||
其管理的所有 Pod 将被一并回收。
|
||||
</p>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={() => setModal(null)}>取消</Button>
|
||||
<Button variant="destructive" disabled={actionLoading === 'delete'} onClick={handleDelete}>
|
||||
{actionLoading === 'delete' ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Trash2 className="h-4 w-4 mr-1" />}
|
||||
确认删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
)}
|
||||
|
||||
{/* ============ 弹窗:Pod 日志 ============ */}
|
||||
{modal?.kind === 'podLogs' && (
|
||||
<PodLogsModal
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
podName={modal.pod}
|
||||
containers={(pods.find(p => p.name === modal.pod)?.containers || []).map((c: any) => c.name).filter(Boolean)}
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 通用 Modal 外壳 ============
|
||||
function ModalShell({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-xl p-6 w-[460px] max-w-[90vw] shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-base font-bold text-slate-100">{title}</h2>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400" onClick={onClose}><X className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Pod 日志弹窗(带容器切换 + 行数 + 实时流) ============
|
||||
function PodLogsModal({ clusterCode, namespace, podName, containers, onClose }: {
|
||||
clusterCode: string; namespace: string; podName: string; containers: string[]; onClose: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [logs, setLogs] = useState('');
|
||||
const [tail, setTail] = useState(200);
|
||||
const [container, setContainer] = useState(containers[0] || '');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
|
||||
const loadLogs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res: any = await kubernetesApi.getPodLogs(clusterCode, namespace, podName, tail, container || undefined);
|
||||
setLogs(res?.data?.logs || res?.logs || res || '无日志');
|
||||
} catch {
|
||||
setLogs('获取日志失败');
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { loadLogs(); }, [container, tail]);
|
||||
|
||||
const startStream = async () => {
|
||||
setStreaming(true);
|
||||
const token = useUserStore.getState().token || '';
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
|
||||
const cParam = container ? `&container=${encodeURIComponent(container)}` : '';
|
||||
const url = `${baseUrl}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/logs?tail=${tail}&stream=true${cParam}`;
|
||||
try {
|
||||
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||
const reader = resp.body?.getReader();
|
||||
if (!reader) return;
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
setLogs(buf);
|
||||
}
|
||||
} catch {
|
||||
toast({ title: '流式日志连接中断', variant: 'destructive' });
|
||||
} finally { setStreaming(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-xl w-[90vw] max-w-4xl max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-3 border-b border-slate-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-sm font-semibold text-slate-200">{podName}</span>
|
||||
{container && <Badge variant="outline" className="border-slate-700 text-slate-400 text-[10px]">{container}</Badge>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{containers.length > 0 && (
|
||||
<select value={container} onChange={e => setContainer(e.target.value)}
|
||||
className="bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-2 py-1">
|
||||
{containers.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<span className="text-[11px] text-slate-500">最近</span>
|
||||
<Input type="number" value={tail} onChange={e => setTail(Number(e.target.value) || 200)}
|
||||
className="w-16 h-7 bg-slate-800 border-slate-700 text-slate-200 text-xs" />
|
||||
<span className="text-[11px] text-slate-500">行</span>
|
||||
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={loadLogs} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-emerald-400 text-xs" onClick={startStream} disabled={streaming}>
|
||||
{streaming ? '流中...' : '实时流'}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400" onClick={onClose}><X className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-3">
|
||||
<pre className="bg-slate-950 rounded p-3 text-xs text-slate-300 font-mono whitespace-pre-wrap max-h-[60vh] overflow-auto">
|
||||
{logs || '无日志内容'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user