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,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>
);
}