feat(k8s): M11 StatefulSet/DaemonSet 详情页 — WorkloadViewPage 重写 + YAML 路由冲突修复

后端:
- routes.go: 为 statefulsets/daemonsets 注册专门 YAML 子路由(Gin radix tree 冲突)
- kubernetes.go: GetResourceYAMLNS/UpdateResourceYAMLNS 添加 URL path fallback

前端:
- WorkloadViewPage.tsx 重写: 修复 kind 提取 bug(useLocation) + 更新策略/选择器/PVC模板/InitContainers
- NamespaceResourcePage.tsx: handleDetail 添加 STS/DS 点击导航

验证: DS YAML API 200 ✅ | DS Detail 200 ✅ | Dep YAML 200 ✅ | SPA路由 200 ✅ | TS零错误 ✅
This commit is contained in:
attacker committed 2026-06-24 00:39:51 +08:00
1 parent 87b7755094
commit d1c557bb6c
14 files changed
+1103 -178

No files matched your search

@@ -0,0 +1,572 @@
// WorkloadViewPage.tsx — StatefulSet / DaemonSet 详情页(对标 DeploymentWorkload)
// 支持 view / yaml 双模式
// 作者: Logan <admin@attacker.club>
import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
import {
ArrowLeft, RotateCcw, FileCode, Activity, Container, Terminal,
HardDrive, Minus, Plus, Boxes, Tag, Settings, Database,
} from 'lucide-react';
import { kubernetesApi } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import { YamlViewer } from './YamlViewer';
import { PodExecTerminal } from './PodExecTerminal';
import { PodFileBrowser } from './PodFileBrowser';
import { formatK8sTime, statusColor, STATUS_COLORS } from './k8s-yaml-utils';
type WorkloadKind = 'StatefulSet' | 'DaemonSet';
type ViewMode = 'view' | 'yaml';
function StatCard({ label, value, sub, color }: { label: string; value: React.ReactNode; sub?: string; color?: string }) {
return (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-3">
<p className="text-xs text-slate-500">{label}</p>
<p className="text-base font-bold mt-0.5" style={{ color: color || '#e2e8f0' }}>{value}</p>
{sub && <p className="text-[10px] text-slate-600 mt-0.5">{sub}</p>}
</CardContent>
</Card>
);
}
// Helper: render key-value pairs as badges
function LabelBadges({ labels }: { labels: Record<string, string> | undefined }) {
const entries = Object.entries(labels || {});
if (entries.length === 0) return <span className="text-xs text-slate-600">无</span>;
return (
<div className="flex flex-wrap gap-1.5">
{entries.map(([k, v]) => (
<Badge key={k} variant="outline" className="border-slate-700 text-slate-400 text-[10px] font-mono">
{k}={v}
</Badge>
))}
</div>
);
}
export default function WorkloadViewPage() {
const params = useParams<{ clusterCode: string; namespace: string; name: string; mode?: string }>();
const navigate = useNavigate();
const location = useLocation();
const { toast } = useToast();
const clusterCode = params.clusterCode || '';
const namespace = params.namespace || '';
const name = params.name || '';
// ⚠️ Fix: kind 是静态路径段(StatefulSet/DaemonSet),不是 :param
// 用 useLocation().pathname 解析,而非 useParams
const kind: WorkloadKind = location.pathname.includes('/DaemonSet/') ? 'DaemonSet' : 'StatefulSet';
const mode: ViewMode = params.mode === 'yaml' ? 'yaml' : 'view';
const [manifest, setManifest] = useState<any>(null);
const [pods, setPods] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [scaleModal, setScaleModal] = useState(false);
const [scaleValue, setScaleValue] = useState(1);
const [actionLoading, setActionLoading] = useState(false);
const [terminalPod, setTerminalPod] = useState<any>(null);
const [fileBrowserPod, setFileBrowserPod] = useState<any>(null);
const [yamlModal, setYamlModal] = useState(false);
const loadData = useCallback(async () => {
setLoading(true);
try {
const [yamlRes, podsRes] = await Promise.all([
kubernetesApi.getResourceYaml(clusterCode, namespace, kind.toLowerCase() + 's', name).catch(() => ''),
kubernetesApi.getPods(clusterCode).catch(() => []),
]);
// yamlRes is raw YAML text — try to parse as JSON (K8s API accepts JSON as YAML superset)
let parsed: any = null;
if (yamlRes && typeof yamlRes === 'string') {
try { parsed = JSON.parse(yamlRes); } catch { parsed = { _raw: yamlRes }; }
} else if (yamlRes && typeof yamlRes === 'object') {
parsed = yamlRes;
}
setManifest(parsed);
// Filter pods by name prefix and namespace
const podList = Array.isArray(podsRes) ? podsRes : (podsRes?.list || podsRes?.data || []);
const filtered = podList.filter((p: any) => {
const podName = p.name || p.metadata?.name || '';
const podNs = p.namespace || p.namespace_name || '';
return podName.startsWith(name) && (!podNs || podNs === namespace);
});
setPods(filtered);
} catch (e) {
console.error('load error', e);
} finally {
setLoading(false);
}
}, [clusterCode, namespace, name, kind]);
useEffect(() => { loadData(); }, [loadData]);
const handleModeChange = (m: ViewMode) => {
if (m === mode) return;
navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/workload/${m}/${kind}/${name}`, { replace: true });
};
const handleRestart = async () => {
if (!confirm(`确认重启 ${kind} ${name}?\n将触发滚动重启。`)) return;
setActionLoading(true);
try {
if (kind === 'StatefulSet') {
await kubernetesApi.restartStatefulSet(clusterCode, namespace, name);
} else {
await kubernetesApi.restartDaemonSet(clusterCode, namespace, name);
}
toast({ title: '重启指令已下发', description: `${kind} 滚动重启已触发` });
setTimeout(loadData, 1000);
} catch (e: any) {
toast({ title: '重启失败', description: e?.message || String(e), variant: 'destructive' });
} finally { setActionLoading(false); }
};
const handleScale = async () => {
setActionLoading(true);
try {
await kubernetesApi.scaleStatefulSet(clusterCode, namespace, name, scaleValue);
toast({ title: '扩缩容成功', description: `StatefulSet 副本数已更新为 ${scaleValue}` });
setScaleModal(false);
setTimeout(loadData, 1000);
} catch (e: any) {
toast({ title: '扩缩容失败', description: e?.message || String(e), variant: 'destructive' });
} finally { setActionLoading(false); }
};
// Parse status from manifest
const spec = manifest?.spec || {};
const status = manifest?.status || {};
const containers: any[] = spec?.template?.spec?.containers || [];
const initContainers: any[] = spec?.template?.spec?.initContainers || [];
// For StatefulSet
const replicas = spec.replicas ?? 1;
const readyReplicas = status.readyReplicas ?? 0;
const currentReplicas = status.currentReplicas ?? 0;
const updatedReplicas = status.updatedReplicas ?? 0;
// For DaemonSet
const desiredScheduled = status.desiredNumberScheduled ?? 0;
const currentScheduled = status.currentNumberScheduled ?? 0;
const numberReady = status.numberReady ?? 0;
const numberAvailable = status.numberAvailable ?? 0;
const numberUpdated = status.updatedNumberScheduled ?? 0;
const isSts = kind === 'StatefulSet';
const isHealthy = isSts
? (readyReplicas === replicas && replicas > 0)
: (numberReady === desiredScheduled && desiredScheduled > 0);
// Update strategy
const updateStrategy = spec.updateStrategy || {};
const strategyType = updateStrategy.type || (isSts ? 'RollingUpdate' : 'RollingUpdate');
// VolumeClaimTemplates (StatefulSet only)
const vcts: any[] = spec.volumeClaimTemplates || [];
// Selector
const selectorLabels = spec.selector?.matchLabels || {};
// Labels & Annotations
const labels = manifest?.metadata?.labels || {};
const annotations = manifest?.metadata?.annotations || {};
return (
<div className="p-4 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-200" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<Boxes className="h-5 w-5 text-blue-400" />
<div>
<div className="flex items-center gap-2">
<h1 className="text-lg font-bold text-slate-100">{name}</h1>
<Badge variant="outline" className="border-slate-700 text-slate-400">{kind}</Badge>
<Badge variant="outline" className="border-slate-700 text-slate-400">{namespace}</Badge>
{isHealthy
? <Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30">健康</Badge>
: <Badge className="bg-amber-500/20 text-amber-400 border-amber-500/30">更新中</Badge>}
</div>
<p className="text-xs text-slate-500 mt-0.5">集群: {clusterCode}</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Mode tabs */}
<div className="flex items-center gap-1 bg-slate-900 border border-slate-800 rounded-lg p-0.5">
<button
className={`px-3 py-1 text-xs rounded transition-colors ${mode === 'view' ? 'bg-blue-500/20 text-blue-400' : 'text-slate-400 hover:text-slate-200'}`}
onClick={() => handleModeChange('view')}
>查看</button>
<button
className={`px-3 py-1 text-xs rounded transition-colors ${mode === 'yaml' ? 'bg-blue-500/20 text-blue-400' : 'text-slate-400 hover:text-slate-200'}`}
onClick={() => handleModeChange('yaml')}
>YAML</button>
</div>
{/* Actions */}
{isSts && (
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300 hover:text-slate-100"
onClick={() => { setScaleValue(replicas); setScaleModal(true); }}>
<Boxes className="h-3.5 w-3.5 mr-1" />伸缩
</Button>
)}
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300 hover:text-slate-100"
disabled={actionLoading} onClick={handleRestart}>
<RotateCcw className="h-3.5 w-3.5 mr-1" />{actionLoading ? '重启中...' : '重启'}
</Button>
<Button variant="outline" size="sm" className="h-8 border-slate-700 text-slate-300 hover:text-slate-100"
onClick={() => setYamlModal(true)}>
<FileCode className="h-3.5 w-3.5 mr-1" />YAML
</Button>
</div>
</div>
{loading ? (
<div className="text-center text-slate-500 py-20">加载中...</div>
) : mode === 'yaml' ? (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<YamlViewer
clusterCode={clusterCode}
namespace={namespace}
resource={kind.toLowerCase() + 's'}
name={name}
onClose={() => {}}
inline
autoEdit
/>
</CardContent>
</Card>
) : (
<>
{/* Status cards */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{isSts ? (
<>
<StatCard label="副本就绪" value={`${readyReplicas}/${replicas}`} color={isHealthy ? STATUS_COLORS.green : STATUS_COLORS.orange} sub={isHealthy ? '全部就绪' : '部分未就绪'} />
<StatCard label="当前副本" value={currentReplicas} color={STATUS_COLORS.blue} />
<StatCard label="已更新副本" value={updatedReplicas} color={STATUS_COLORS.green} />
<StatCard label="服务名" value={spec.serviceName || '-'} color="#94a3b8" />
<StatCard label="Pod 数量" value={pods.length} color="#94a3b8" />
<StatCard label="创建时间" value={<span className="text-xs">{formatK8sTime(manifest?.metadata?.creationTimestamp)}</span>} color="#94a3b8" />
</>
) : (
<>
<StatCard label="期望调度" value={desiredScheduled} color={STATUS_COLORS.blue} />
<StatCard label="当前调度" value={currentScheduled} color="#94a3b8" />
<StatCard label="就绪数量" value={numberReady} color={isHealthy ? STATUS_COLORS.green : STATUS_COLORS.orange} />
<StatCard label="可用数量" value={numberAvailable} color={STATUS_COLORS.green} />
<StatCard label="已更新" value={numberUpdated} color="#94a3b8" />
<StatCard label="创建时间" value={<span className="text-xs">{formatK8sTime(manifest?.metadata?.creationTimestamp)}</span>} color="#94a3b8" />
</>
)}
</div>
{/* Update Strategy + Selector */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<Settings className="h-4 w-4 text-amber-400" />更新策略
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-2 text-xs">
<div className="flex justify-between">
<span className="text-slate-500">策略类型</span>
<Badge variant="outline" className="border-slate-700 text-slate-300">{strategyType}</Badge>
</div>
{strategyType === 'RollingUpdate' && isSts && updateStrategy.rollingUpdate && (
<>
<div className="flex justify-between">
<span className="text-slate-500">分区 (partition)</span>
<span className="text-slate-300 font-mono">{updateStrategy.rollingUpdate.partition ?? 0}</span>
</div>
{updateStrategy.rollingUpdate.maxUnavailable && (
<div className="flex justify-between">
<span className="text-slate-500">最大不可用</span>
<span className="text-slate-300 font-mono">{typeof updateStrategy.rollingUpdate.maxUnavailable === 'object' ? JSON.stringify(updateStrategy.rollingUpdate.maxUnavailable) : updateStrategy.rollingUpdate.maxUnavailable}</span>
</div>
)}
</>
)}
{strategyType === 'RollingUpdate' && !isSts && updateStrategy.rollingUpdate && (
<>
{updateStrategy.rollingUpdate.maxUnavailable && (
<div className="flex justify-between">
<span className="text-slate-500">最大不可用</span>
<span className="text-slate-300 font-mono">{typeof updateStrategy.rollingUpdate.maxUnavailable === 'object' ? JSON.stringify(updateStrategy.rollingUpdate.maxUnavailable) : updateStrategy.rollingUpdate.maxUnavailable}</span>
</div>
)}
{updateStrategy.rollingUpdate.maxSurge && (
<div className="flex justify-between">
<span className="text-slate-500">最大 surge</span>
<span className="text-slate-300 font-mono">{typeof updateStrategy.rollingUpdate.maxSurge === 'object' ? JSON.stringify(updateStrategy.rollingUpdate.maxSurge) : updateStrategy.rollingUpdate.maxSurge}</span>
</div>
)}
</>
)}
{isSts && (
<div className="flex justify-between">
<span className="text-slate-500">Pod 管理策略</span>
<span className="text-slate-300 font-mono">{spec.podManagementPolicy || 'OrderedReady'}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-slate-500">修订历史限制</span>
<span className="text-slate-300 font-mono">{spec.revisionHistoryLimit ?? 10}</span>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<Tag className="h-4 w-4 text-purple-400" />选择器与标签
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-3 text-xs">
<div>
<p className="text-slate-500 mb-1.5">Selector (matchLabels)</p>
<LabelBadges labels={selectorLabels} />
</div>
<div>
<p className="text-slate-500 mb-1.5">Labels</p>
<LabelBadges labels={labels} />
</div>
{Object.keys(annotations).length > 0 && (
<div>
<p className="text-slate-500 mb-1.5">Annotations</p>
<div className="flex flex-wrap gap-1.5">
{Object.entries(annotations).slice(0, 8).map(([k, v]) => (
<Badge key={k} variant="outline" className="border-slate-700 text-slate-500 text-[10px] font-mono max-w-[300px] truncate">
{k}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
</div>
{/* VolumeClaimTemplates (StatefulSet only) */}
{isSts && vcts.length > 0 && (
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<Database className="h-4 w-4 text-cyan-400" />存储卷声明模板 (VolumeClaimTemplates)
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">名称</TableHead>
<TableHead className="text-slate-400">访问模式</TableHead>
<TableHead className="text-slate-400">存储类</TableHead>
<TableHead className="text-slate-400">大小</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{vcts.map((vct: any, idx: number) => {
const accessModes = (vct.spec?.accessModes || []).join(', ');
const storageClass = vct.spec?.storageClassName || vct.spec?.storageClassName || '-';
const size = vct.spec?.resources?.requests?.storage || '-';
return (
<TableRow key={idx} className="border-slate-800">
<TableCell className="font-medium text-slate-200">{vct.metadata?.name || '-'}</TableCell>
<TableCell className="text-sm text-slate-400">{accessModes || '-'}</TableCell>
<TableCell className="text-sm text-slate-400">{storageClass}</TableCell>
<TableCell className="text-sm text-slate-300 font-mono">{size}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* Container template */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<Container className="h-4 w-4 text-blue-400" />容器模板(Pod Template)
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
{initContainers.length > 0 && (
<div className="mb-3">
<p className="text-[11px] text-slate-500 mb-2 uppercase tracking-wide">Init Containers</p>
<div className="space-y-2">
{initContainers.map((c: any, idx: number) => (
<ContainerInfo key={`init-${idx}`} c={c} />
))}
</div>
</div>
)}
{containers.length === 0 ? (
<p className="text-xs text-slate-500 py-3">暂无容器数据,请查看 YAML</p>
) : (
<div className="space-y-3">
<p className="text-[11px] text-slate-500 mb-1 uppercase tracking-wide">Containers</p>
{containers.map((c: any, idx: number) => (
<ContainerInfo key={idx} c={c} />
))}
</div>
)}
</CardContent>
</Card>
{/* Pod list */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-200 flex items-center gap-2">
<Activity className="h-4 w-4 text-emerald-400" />Pod 实例 ({pods.length})
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">Pod 名称</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400">所在节点</TableHead>
<TableHead className="text-slate-400">Pod IP</TableHead>
<TableHead className="text-slate-400">重启</TableHead>
<TableHead className="text-slate-400">操作</TableHead>
</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 style={{ background: `${statusColor(pod.status)}22`, color: statusColor(pod.status), border: `1px solid ${statusColor(pod.status)}44` }}>
{pod.status || 'Unknown'}
</Badge>
</TableCell>
<TableCell className="text-sm text-slate-400">{pod.node_name || pod.node || '-'}</TableCell>
<TableCell className="text-sm text-slate-400 font-mono">{pod.ip || pod.pod_ip || '-'}</TableCell>
<TableCell className={(pod.restart_count || 0) > 0 ? 'text-yellow-400' : 'text-slate-500'}>{pod.restart_count || 0}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/10"
onClick={() => setTerminalPod(pod)}>
<Terminal className="h-3.5 w-3.5 mr-1" />终端
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-amber-400 hover:text-amber-300 hover:bg-amber-500/10"
onClick={() => setFileBrowserPod(pod)}>
<HardDrive className="h-3.5 w-3.5 mr-1" />文件浏览器
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</>
)}
{/* Scale modal */}
{scaleModal && (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" onClick={() => setScaleModal(false)}>
<div className="bg-slate-900 border border-slate-800 rounded-lg p-6 w-80" onClick={e => e.stopPropagation()}>
<h3 className="text-sm font-semibold text-slate-200 mb-4">伸缩 StatefulSet</h3>
<div className="flex items-center justify-center gap-4 mb-4">
<Button variant="outline" size="icon" className="h-9 w-9" onClick={() => setScaleValue(Math.max(0, scaleValue - 1))}>
<Minus className="h-4 w-4" />
</Button>
<span className="text-2xl font-bold text-slate-100 w-12 text-center">{scaleValue}</span>
<Button variant="outline" size="icon" className="h-9 w-9" onClick={() => setScaleValue(scaleValue + 1)}>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" className="flex-1 border-slate-700 text-slate-400" onClick={() => setScaleModal(false)}>取消</Button>
<Button size="sm" className="flex-1 bg-blue-600 hover:bg-blue-500" disabled={actionLoading} onClick={handleScale}>
{actionLoading ? '执行中...' : '确认'}
</Button>
</div>
</div>
</div>
)}
{/* YAML modal */}
{yamlModal && (
<YamlViewer
clusterCode={clusterCode}
namespace={namespace}
resource={kind.toLowerCase() + 's'}
name={name}
onClose={() => setYamlModal(false)}
/>
)}
{/* Pod terminal */}
{terminalPod && (
<PodExecTerminal
open={true}
onOpenChange={(o) => { if (!o) setTerminalPod(null); }}
clusterId={clusterCode}
namespace={namespace}
podName={terminalPod.name}
containers={terminalPod.containers?.map((c: any) => c.name).filter(Boolean) || []}
/>
)}
{/* Pod file browser */}
{fileBrowserPod && (
<PodFileBrowser
open={true}
onOpenChange={(o) => { if (!o) setFileBrowserPod(null); }}
clusterCode={clusterCode}
namespace={namespace}
podName={fileBrowserPod.name}
containers={fileBrowserPod.containers?.map((c: any) => c.name).filter(Boolean) || []}
/>
)}
</div>
);
}
// Container info sub-component
function ContainerInfo({ c }: { c: any }) {
return (
<div className="border border-slate-800 rounded-lg p-3 bg-slate-950/30">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Container className="h-4 w-4 text-blue-400" />
<span className="text-sm font-semibold text-slate-200">{c.name}</span>
</div>
<code className="text-[11px] text-slate-400 font-mono truncate max-w-[400px]">{c.image}</code>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
<div className="text-slate-400">
CPU: <span className="text-slate-200 font-mono">{c.resources?.requests?.cpu || '-'}/{c.resources?.limits?.cpu || '-'}</span>
</div>
<div className="text-slate-400">
内存: <span className="text-slate-200 font-mono">{c.resources?.requests?.memory || '-'}/{c.resources?.limits?.memory || '-'}</span>
</div>
<div className="text-slate-400">
端口: <span className="text-slate-200 font-mono">
{(c.ports || []).map((p: any) => p.containerPort || p.port).join(', ') || '-'}
</span>
</div>
</div>
</div>
);
}