Files
aiops/frontend/src/components/kubernetes/DeploymentWorkload.tsx
T
attacker 2e66908e20 feat(k8s): M5 Deployment 暂停更新 + CI/CD 集成
- 后端: PauseDeployment/ResumeDeployment 客户端方法 (PATCH spec.paused)
- 后端: ID路由 + Kuboard code路由 pause/resume handlers
- 后端: Deployment 列表 summary 增加 paused 字段
- 前端: 操作菜单增加「暂停更新/恢复更新」动态切换 + 暂停 Badge
- 前端: CI/CD 发布按钮跳转流水线页面 (namespace+deployment 上下文)
- 前端: api.ts 增加 pauseDeployment/resumeDeployment 方法
- 验证: 5项端到端测试全部通过 (Python urllib)
2026-06-23 03:06:13 +08:00

615 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// DeploymentWorkload.tsx — Kuboard 风格 Deployment 详情主组件
// - 顶部操作菜单: 编辑 | 预览/编辑YAML | 调整镜像版本 | 伸缩 | 日志/终端 | 重启 | 暂停/恢复更新 | CI/CD发布 | 删除 | 元数据 | 运行时 | 服务 | 应用路由
// - 双模式: 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,
Pause, Play, Rocket,
} 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 handleTogglePause = async () => {
const isPaused = !!deployment?.paused;
const action = isPaused ? '恢复' : '暂停';
if (!confirm(`确认${action} Deployment "${deploymentName}" 的滚动更新?`)) return;
setActionLoading(isPaused ? 'resume' : 'pause');
try {
if (isPaused) {
await kubernetesApi.resumeDeployment(clusterCode, namespace, deploymentName);
} else {
await kubernetesApi.pauseDeployment(clusterCode, namespace, deploymentName);
}
toast({ title: `${action}成功`, description: `Deployment 滚动更新已${action}` });
setTimeout(loadData, 1000);
} catch (e: any) {
toast({ title: `${action}失败`, description: e?.message || String(e), variant: 'destructive' });
} finally { setActionLoading(''); }
};
const handleCICD = () => {
// 跳转到流水线页面,传递 namespace + deployment 上下文
navigate(`/pipelines?namespace=${encodeURIComponent(namespace)}&app=${encodeURIComponent(deploymentName)}`);
};
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>
{deployment?.paused && (
<Badge className="bg-orange-500/20 text-orange-400 border-orange-500/30">
<Pause className="h-3 w-3 mr-1" />已暂停更新
</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-slate-200 hover:bg-slate-800 cursor-pointer"
disabled={!!actionLoading}
onClick={handleTogglePause}
>
{deployment?.paused
? <><Play className="h-4 w-4 mr-2 text-green-400" /> 恢复更新</>
: <><Pause className="h-4 w-4 mr-2 text-orange-400" /> 暂停更新</>
}
</DropdownMenuItem>
<DropdownMenuItem
className="text-slate-200 hover:bg-slate-800 cursor-pointer"
onClick={handleCICD}
>
<Rocket className="h-4 w-4 mr-2 text-purple-400" /> CI/CD 发布
</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>
);
}