Files
aiops/frontend/src/components/kubernetes/DeploymentView.tsx
T
attacker d581441fa8 feat: Deployment详情页重构 — ACK风格Tab布局 + CI/CD Webhook + 信息面板
DeploymentView 完全重写:
- Tab布局: 容器组|访问方式(Service/Ingress)|事件|容器伸缩|历史版本
- 顶部信息面板: 状态/副本/节点/IP/创建时间 + Pod条件时间线
- 容器组Tab: 卡片式列表(日志/终端/文件按钮), 展开显示容器详情
- 访问方式Tab: 自动加载匹配的Service+Ingress(selector过滤)
- 事件Tab: 加载Deployment相关K8s Events
- 容器伸缩Tab: 内联伸缩UI(+/-按钮)
- 历史版本Tab: 回滚功能

DeploymentWorkload 精简:
- 删除下拉菜单(元数据/运行时/服务/应用路由/YAML预览/日志终端)
- 操作按钮改为直接按钮行: 编辑|调整镜像|重启|暂停/恢复|CI/CD|删除
- CI/CD改为Webhook模态(生成URL+curl示例, 自动填充Token)
- AIOpsAssistant UUID fallback支持HTTP环境
- Dockerfile chmod a+r 修复favicon.svg 403
2026-06-24 03:55:23 +00:00

584 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.
// DeploymentView.tsx — ACK 风格 Tab 详情看板
// Tabs: 容器组 | 访问方式 | 事件 | 容器伸缩 | 历史版本
// 作者: Logan <admin@attacker.club>
import { useState, useEffect, Fragment } from 'react';
import {
Card, CardContent, CardHeader, CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
import {
Clock, Cpu, MemoryStick, HardDrive, Network, Activity,
RotateCcw, Container, History, ChevronDown, ChevronRight,
Terminal, FileText, Server, ExternalLink, Minus, Plus,
Loader2,
} from 'lucide-react';
import { kubernetesApi } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import {
formatK8sTime, formatDuration, elapsedSeconds, statusColor, STATUS_COLORS,
} from './k8s-yaml-utils';
// Types
interface PodItem {
name: string; namespace?: string; status?: string; node_name?: string; node?: string;
ip?: string; pod_ip?: string; restart_count?: number; age?: string; created_at?: string;
start_time?: string; containers?: any[]; image?: string; images?: string[];
}
interface ReplicaSetItem {
name: string; replicas?: number; ready_replicas?: number; available_replicas?: number;
image?: string; revision?: number; status?: string; created_at?: string;
}
interface SvcItem { name: string; type: string; clusterIP: string; ports: any[]; selector: Record<string, string>; age: string; }
interface IngItem { name: string; className: string; hosts: string[]; rules: any[]; age: string; }
interface EventItem { type: string; reason: string; message: string; source: string; firstTimestamp: string; lastTimestamp: string; count: number; }
interface DeploymentViewProps {
clusterCode: string; namespace: string; deployment: any;
pods: PodItem[]; replicaSets: ReplicaSetItem[]; revisions: any[];
onRefresh: () => void;
onShowPodLogs: (pod: PodItem) => void;
onShowPodTerminal: (pod: PodItem) => void;
onShowPodYaml: (pod: PodItem) => void;
onShowPodFileBrowser: (pod: PodItem) => void;
}
// ─── Helpers ───
function getPodTimeline(pod: PodItem) {
const cond: any[] = (pod as any).conditions || (pod as any).pod_conditions || [];
const find = (t: string) => cond.find((c: any) => (c.type || c.Type) === t);
const ts = (c: any) => c?.lastTransitionTime || c?.LastTransitionTime || c?.last_transition_time;
return {
scheduled: ts(find('PodScheduled') || {}),
initialized: ts(find('Initialized') || {}),
containersReady: ts(find('ContainersReady') || {}),
ready: ts(find('Ready') || {}),
startTime: pod.start_time || (pod as any).startTime,
};
}
export function DeploymentView(props: DeploymentViewProps) {
const { clusterCode, namespace, deployment, pods, replicaSets, revisions, onRefresh, onShowPodLogs, onShowPodTerminal, onShowPodYaml, onShowPodFileBrowser } = props;
const { toast } = useToast();
const [tab, setTab] = useState('pods');
const [expandedPod, setExpandedPod] = useState<string | null>(null);
const [rollbackLoading, setRollbackLoading] = useState<number | null>(null);
// Scale state
const [scaleValue, setScaleValue] = useState(deployment?.replicas || 0);
const [scaleLoading, setScaleLoading] = useState(false);
// Service/Ingress/Events state
const [services, setServices] = useState<SvcItem[]>([]);
const [ingresses, setIngresses] = useState<IngItem[]>([]);
const [events, setEvents] = useState<EventItem[]>([]);
const [tabLoading, setTabLoading] = useState('');
const ready = deployment?.ready_replicas ?? deployment?.readyReplicas ?? 0;
const total = deployment?.replicas ?? 0;
const isHealthy = ready === total && total > 0;
// Load Services and Ingresses for "访问方式" tab
useEffect(() => {
if (tab !== 'services') return;
setTabLoading('services');
Promise.all([
kubernetesApi.getNamespaceServices(clusterCode, namespace).catch(() => []),
kubernetesApi.getNamespaceIngresses(clusterCode, namespace).catch(() => []),
]).then(([svcRes, ingRes]) => {
const toArr = (v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || []));
const allSvcs = toArr(svcRes);
const allIngs = toArr(ingRes);
// Filter services matching deployment selector
const selector = deployment?.spec?.selector?.matchLabels || deployment?.spec?.template?.metadata?.labels || {};
const matchedSvcs = allSvcs.filter((s: any) => {
const svcSel = s.spec?.selector || s.selector || {};
if (Object.keys(svcSel).length === 0) return false;
return Object.entries(svcSel).every(([k, v]) => selector[k] === v);
}).map((s: any) => ({
name: s.name || s.metadata?.name,
type: s.spec?.type || s.type || 'ClusterIP',
clusterIP: s.spec?.clusterIP || s.cluster_ip || '<none>',
ports: (s.spec?.ports || s.ports || []).map((p: any) => `${p.port}${p.targetPort ? ':' + p.targetPort : ''}/${p.protocol || 'TCP'}`),
selector: s.spec?.selector || s.selector || {},
age: s.age || '',
}));
// Filter ingresses pointing to matched services
const svcNames = new Set(matchedSvcs.map((s: any) => s.name));
const matchedIngs = allIngs.filter((i: any) => {
const rules = i.spec?.rules || i.rules || [];
return rules.some((r: any) =>
(r.http?.paths || r.paths || []).some((p: any) => {
const backendSvc = p.backend?.service?.name || p.backendService || '';
return svcNames.has(backendSvc);
})
);
}).map((i: any) => ({
name: i.name || i.metadata?.name,
className: i.spec?.ingressClassName || i.ingressClass || '-',
hosts: (i.spec?.rules || i.rules || []).map((r: any) => r.host).filter(Boolean),
rules: i.spec?.rules || i.rules || [],
age: i.age || '',
}));
setServices(matchedSvcs);
setIngresses(matchedIngs);
}).catch(() => {}).finally(() => setTabLoading(''));
}, [tab, clusterCode, namespace, deployment]);
// Load Events for "事件" tab
useEffect(() => {
if (tab !== 'events') return;
setTabLoading('events');
kubernetesApi.getEvents(clusterCode, namespace, undefined).then((res: any) => {
const allEvents = Array.isArray(res) ? res : (res?.list || res?.items || res?.data || []);
const depEvents = allEvents.filter((e: any) => {
const involved = e.involvedObject || e.involved_object || {};
const objName = involved.name || '';
return objName === deployment?.name || objName.startsWith(deployment?.name + '-');
}).map((e: any) => ({
type: e.type || 'Normal',
reason: e.reason || '',
message: e.message || '',
source: (e.source?.component || e.sourceComponent || '-'),
firstTimestamp: e.firstTimestamp || e.first_timestamp || '',
lastTimestamp: e.lastTimestamp || e.last_timestamp || '',
count: e.count || 1,
}));
setEvents(depEvents);
}).catch(() => {}).finally(() => setTabLoading(''));
}, [tab, clusterCode, namespace, deployment?.name]);
// Rollback
const handleRollback = async (rev: any) => {
const revision = rev.revision ?? rev.revision_number ?? rev.number;
if (!confirm(`确认回滚到修订版本 #${revision}?`)) return;
setRollbackLoading(revision);
try {
await kubernetesApi.rollbackDeployment(clusterCode, namespace, deployment.name, Number(revision));
toast({ title: '回滚已下发', description: `正在回滚到 #${revision}` });
onRefresh();
} catch (e: any) {
toast({ title: '回滚失败', description: e?.message || String(e), variant: 'destructive' });
} finally { setRollbackLoading(null); }
};
// Scale
const handleScale = async () => {
setScaleLoading(true);
try {
await kubernetesApi.scaleDeployment(clusterCode, namespace, deployment.name, scaleValue);
toast({ title: '伸缩已提交', description: `副本数 → ${scaleValue}` });
onRefresh();
} catch (e: any) {
toast({ title: '伸缩失败', description: e?.message || String(e), variant: 'destructive' });
} finally { setScaleLoading(false); }
};
const TABS = [
{ key: 'pods', label: '容器组', icon: Container, count: pods.length },
{ key: 'services', label: '访问方式', icon: Network, count: services.length },
{ key: 'events', label: '事件', icon: Activity, count: events.length },
{ key: 'scale', label: '容器伸缩', icon: Plus },
{ key: 'history', label: '历史版本', icon: History, count: revisions.length },
];
return (
<div className="space-y-4">
{/* ============ Info Panel (ACK-style) ============ */}
<Card className="dg-card">
<CardContent className="p-4">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 text-sm">
<InfoItem label="状态" value={
<Badge style={{ background: `${isHealthy ? STATUS_COLORS.green : STATUS_COLORS.orange}22`, color: isHealthy ? STATUS_COLORS.green : STATUS_COLORS.orange, border: `1px solid ${isHealthy ? STATUS_COLORS.green : STATUS_COLORS.orange}44` }}>
{isHealthy ? 'Running' : 'Updating'}
</Badge>
} />
<InfoItem label="副本" value={<span className="font-mono">{ready}/{total}</span>} />
<InfoItem label="策略" value={deployment?.strategy || 'RollingUpdate'} />
{pods[0] && (
<>
<InfoItem label="所在节点" value={
<button className="text-[var(--dg-blue)] hover:underline" onClick={() => window.open(`/kubernetes/${clusterCode}/nodes/${pods[0].node_name || pods[0].node}`, '_blank')}>
{pods[0].node_name || pods[0].node || '-'}
</button>
} />
<InfoItem label="容器组 IP" value={<span className="font-mono">{pods[0].ip || pods[0].pod_ip || '-'}</span>} />
</>
)}
<InfoItem label="创建时间" value={formatK8sTime(deployment?.created_at || deployment?.creationTimestamp)} />
</div>
{/* Pod conditions timeline */}
{pods[0] && (
<div className="mt-3 pt-3 border-t border-[var(--dg-border)]">
<PodConditionsRow pod={pods[0]} />
</div>
)}
</CardContent>
</Card>
{/* ============ Tab Bar ============ */}
<div className="flex items-center gap-0.5 border-b border-[var(--dg-border)] overflow-x-auto">
{TABS.map(t => (
<button
key={t.key}
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
tab === t.key
? 'border-[var(--dg-blue)] text-[var(--dg-blue)]'
: 'border-transparent text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]'
}`}
onClick={() => setTab(t.key)}
>
<t.icon className="h-4 w-4" />
{t.label}
{t.key === 'pods' && <Badge className="ml-1 text-[10px] px-1.5 py-0 bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]">{t.count}</Badge>}
</button>
))}
</div>
{/* ============ Tab Content ============ */}
{tab === 'pods' && <PodsTab {...props} expandedPod={expandedPod} setExpandedPod={setExpandedPod} />}
{tab === 'services' && <ServicesTab services={services} ingresses={ingresses} loading={tabLoading} namespace={namespace} />}
{tab === 'events' && <EventsTab events={events} loading={tabLoading} />}
{tab === 'scale' && (
<ScaleTab
current={total}
value={scaleValue}
onChange={setScaleValue}
onApply={handleScale}
loading={scaleLoading}
/>
)}
{tab === 'history' && <HistoryTab revisions={revisions} rollbackLoading={rollbackLoading} onRollback={handleRollback} />}
</div>
);
}
// ─── Info Item ───
function InfoItem({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div>
<p className="text-xs text-[var(--dg-text-muted)] mb-1">{label}</p>
<div className="text-sm text-[var(--dg-text-primary)]">{value}</div>
</div>
);
}
// ─── Pod Conditions Row ───
function PodConditionsRow({ pod }: { pod: PodItem }) {
const tl = getPodTimeline(pod);
const stages = [
{ label: '已调度', ts: tl.scheduled, color: STATUS_COLORS.blue },
{ label: '已初始化', ts: tl.initialized, color: STATUS_COLORS.orange },
{ label: '容器已就绪', ts: tl.containersReady, color: STATUS_COLORS.green },
{ label: '容器组已就绪', ts: tl.ready, color: STATUS_COLORS.green },
];
return (
<div className="flex items-center gap-2 overflow-x-auto">
{stages.map((s, i) => {
const prev = stages[i - 1];
const dur = elapsedSeconds(prev?.ts, s.ts);
return (
<Fragment key={s.label}>
<div className="flex flex-col items-center min-w-[80px]">
<div className="h-2 w-2 rounded-full" style={{ background: s.ts ? s.color : '#334155' }} />
<span className="text-[10px] mt-1 font-medium" style={{ color: s.ts ? s.color : '#475569' }}>{s.label}</span>
<span className="text-[9px] text-[var(--dg-text-muted)]">{s.ts ? formatK8sTime(s.ts) : '-'}</span>
</div>
{i < stages.length - 1 && (
<div className="flex flex-col items-center mx-0.5 min-w-[30px]">
<div className="h-0.5 w-8 bg-[var(--dg-border)]" />
<span className="text-[9px] text-[var(--dg-text-muted)] mt-0.5">{dur != null ? formatDuration(dur) : ''}</span>
</div>
)}
</Fragment>
);
})}
</div>
);
}
// ═══════════════ TAB: 容器组 ═══════════════
function PodsTab({ clusterCode, namespace, pods, onShowPodLogs, onShowPodTerminal, onShowPodYaml, onShowPodFileBrowser, expandedPod, setExpandedPod }: DeploymentViewProps & { expandedPod: string | null; setExpandedPod: (v: string | null) => void }) {
if (pods.length === 0) {
return <div className="text-center text-[var(--dg-text-muted)] py-12">暂无容器组</div>;
}
return (
<div className="space-y-2">
{pods.map(pod => {
const tl = getPodTimeline(pod);
const expanded = expandedPod === pod.name;
const nodeName = pod.node_name || pod.node;
return (
<Card key={pod.name} className="dg-card">
<CardContent className="p-4">
{/* Pod header row */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" className="h-6 w-6 text-[var(--dg-text-muted)]" onClick={() => setExpandedPod(expanded ? null : pod.name)}>
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
<span className="font-mono text-sm text-[var(--dg-text-primary)]">{pod.name}</span>
<Badge style={{ background: `${statusColor(pod.status)}22`, color: statusColor(pod.status), border: `1px solid ${statusColor(pod.status)}44` }}>
{pod.status || 'Unknown'}
</Badge>
<span className="text-xs text-[var(--dg-text-muted)]">
<button className="text-[var(--dg-blue)] hover:underline mr-2" onClick={() => window.open(`/kubernetes/${clusterCode}/nodes/${nodeName}`, '_blank')}>
{nodeName || '-'}
</button>
</span>
<span className="text-xs font-mono text-[var(--dg-text-muted)]">{pod.ip || pod.pod_ip || '-'}</span>
<span className="text-xs text-[var(--dg-text-muted)]">重启 {pod.restart_count || 0} 次</span>
<span className="text-xs text-[var(--dg-text-muted)]">{formatK8sTime(tl.startTime || pod.created_at)}</span>
<div className="flex-1" />
<Button variant="ghost" size="sm" className="h-7 text-xs text-[var(--dg-blue)]" onClick={() => onShowPodLogs(pod)}>
<FileText className="h-3.5 w-3.5 mr-1" />日志
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs text-emerald-400" onClick={() => onShowPodTerminal(pod)}>
<Terminal className="h-3.5 w-3.5 mr-1" />终端
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs text-amber-400" onClick={() => onShowPodFileBrowser(pod)}>
<HardDrive className="h-3.5 w-3.5 mr-1" />文件
</Button>
</div>
{/* Expanded: timeline */}
{expanded && (
<div className="mt-3 pt-3 border-t border-[var(--dg-border)]">
<PodConditionsRow pod={pod} />
{/* Container details */}
{(pod as any).containers && ((pod as any).containers as any[]).length > 0 && (
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-2">
{((pod as any).containers as any[]).map((c: any, idx: number) => (
<div key={idx} className="p-2 rounded bg-[var(--dg-bg-tertiary)] text-xs">
<div className="flex items-center justify-between">
<span className="font-medium text-[var(--dg-text-primary)]">{c.name}</span>
<span className={c.ready ? 'text-emerald-400' : 'text-[var(--dg-text-muted)]'}>{c.ready ? 'Ready' : 'Pending'}</span>
</div>
<div className="text-[var(--dg-text-muted)] mt-1">{c.image}</div>
{c.resources && (
<div className="mt-1 flex gap-3">
<span>CPU: {c.resources?.requests?.cpu || '0'}/{c.resources?.limits?.cpu || '-'} m</span>
<span>内存: {c.resources?.requests?.memory || '0'}/{c.resources?.limits?.memory || '-'}</span>
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</CardContent>
</Card>
);
})}
</div>
);
}
// ═══════════════ TAB: 访问方式 ═══════════════
function ServicesTab({ services, ingresses, loading, namespace }: { services: SvcItem[]; ingresses: IngItem[]; loading: string; namespace: string }) {
if (loading === 'services') {
return <div className="flex items-center justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-[var(--dg-text-muted)]" /></div>;
}
return (
<div className="space-y-6">
{/* Services */}
<div>
<h3 className="text-sm font-semibold text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
<Network className="h-4 w-4 text-[var(--dg-blue)]" />关联 Service
</h3>
{services.length === 0 ? (
<p className="text-sm text-[var(--dg-text-muted)] py-4">暂无匹配的 Service(Deployment 选择器未关联任何 Service)</p>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="border-[var(--dg-border)]">
<TableHead className="text-[var(--dg-text-muted)]">名称</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">类型</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">ClusterIP</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">端口</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">Selector</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{services.map(svc => (
<TableRow key={svc.name} className="border-[var(--dg-border)]">
<TableCell className="font-medium text-[var(--dg-text-primary)]">{svc.name}</TableCell>
<TableCell><Badge variant="outline" className="text-xs">{svc.type}</Badge></TableCell>
<TableCell className="font-mono text-sm text-[var(--dg-text-muted)]">{svc.clusterIP}</TableCell>
<TableCell className="text-sm text-[var(--dg-text-muted)]">{svc.ports.join(', ')}</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)]">
{Object.entries(svc.selector).map(([k, v]) => (
<code key={k} className="mr-1 bg-[var(--dg-bg-tertiary)] px-1 rounded">{k}={v}</code>
))}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
{/* Ingresses */}
<div>
<h3 className="text-sm font-semibold text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
<ExternalLink className="h-4 w-4 text-[var(--dg-purple)]" />关联 Ingress
</h3>
{ingresses.length === 0 ? (
<p className="text-sm text-[var(--dg-text-muted)] py-4">暂无匹配的 Ingress</p>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="border-[var(--dg-border)]">
<TableHead className="text-[var(--dg-text-muted)]">名称</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">Class</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">Host</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">路径</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ingresses.map(ing => (
<TableRow key={ing.name} className="border-[var(--dg-border)]">
<TableCell className="font-medium text-[var(--dg-text-primary)]">{ing.name}</TableCell>
<TableCell className="text-sm text-[var(--dg-text-muted)]">{ing.className}</TableCell>
<TableCell className="text-sm text-[var(--dg-text-muted)]">{ing.hosts.join(', ')}</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)]">
{ing.rules.flatMap((r: any) => (r.http?.paths || r.paths || []).map((p: any) => p.path || '/')).join(', ')}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</div>
);
}
// ═══════════════ TAB: 事件 ═══════════════
function EventsTab({ events, loading }: { events: EventItem[]; loading: string }) {
if (loading === 'events') {
return <div className="flex items-center justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-[var(--dg-text-muted)]" /></div>;
}
if (events.length === 0) {
return <div className="text-center text-[var(--dg-text-muted)] py-12">暂无事件</div>;
}
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="border-[var(--dg-border)]">
<TableHead className="text-[var(--dg-text-muted)] w-16">类型</TableHead>
<TableHead className="text-[var(--dg-text-muted)] w-24">原因</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">消息</TableHead>
<TableHead className="text-[var(--dg-text-muted)] w-20">来源</TableHead>
<TableHead className="text-[var(--dg-text-muted)] w-10">次数</TableHead>
<TableHead className="text-[var(--dg-text-muted)] w-40">最后时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{events.map((e, i) => (
<TableRow key={i} className="border-[var(--dg-border)]">
<TableCell>
<Badge variant="outline" className={e.type === 'Warning' ? 'text-[var(--dg-yellow)] border-[var(--dg-yellow)]/30' : 'text-[var(--dg-text-muted)] border-[var(--dg-border)]'}>
{e.type}
</Badge>
</TableCell>
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{e.reason}</TableCell>
<TableCell className="text-sm text-[var(--dg-text-primary)] max-w-md truncate" title={e.message}>{e.message}</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)]">{e.source}</TableCell>
<TableCell className="text-sm text-[var(--dg-text-muted)]">{e.count}</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)]">{formatK8sTime(e.lastTimestamp)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
// ═══════════════ TAB: 容器伸缩 ═══════════════
function ScaleTab({ current, value, onChange, onApply, loading }: { current: number; value: number; onChange: (v: number) => void; onApply: () => void; loading: boolean }) {
return (
<Card className="dg-card">
<CardContent className="p-6">
<div className="max-w-sm mx-auto text-center space-y-4">
<p className="text-sm text-[var(--dg-text-muted)]">当前副本数: <span className="font-bold text-[var(--dg-text-primary)]">{current}</span></p>
<div className="flex items-center justify-center gap-4">
<Button variant="outline" size="icon" className="h-10 w-10" disabled={loading || value <= 0} onClick={() => onChange(Math.max(0, value - 1))}>
<Minus className="h-5 w-5" />
</Button>
<Input
type="number" min={0} value={value}
onChange={e => onChange(Math.max(0, Number(e.target.value)))}
className="w-28 text-center text-2xl font-bold"
/>
<Button variant="outline" size="icon" className="h-10 w-10" disabled={loading} onClick={() => onChange(value + 1)}>
<Plus className="h-5 w-5" />
</Button>
</div>
<Button className="w-full" onClick={onApply} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
确认伸缩
</Button>
</div>
</CardContent>
</Card>
);
}
// ═══════════════ TAB: 历史版本 ═══════════════
function HistoryTab({ revisions, rollbackLoading, onRollback }: { revisions: any[]; rollbackLoading: number | null; onRollback: (rev: any) => void }) {
if (revisions.length === 0) {
return <div className="text-center text-[var(--dg-text-muted)] py-12">暂无历史版本</div>;
}
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="border-[var(--dg-border)]">
<TableHead className="text-[var(--dg-text-muted)] w-24">Revision</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">镜像</TableHead>
<TableHead className="text-[var(--dg-text-muted)] w-24">状态</TableHead>
<TableHead className="text-[var(--dg-text-muted)] w-40">更新时间</TableHead>
<TableHead className="text-[var(--dg-text-muted)] w-24">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{revisions.map((rev, idx) => {
const revision = rev.revision ?? rev.revision_number ?? rev.number ?? idx + 1;
const isLatest = idx === 0;
return (
<TableRow key={revision} className="border-[var(--dg-border)]">
<TableCell className="font-mono text-[var(--dg-text-primary)]">#{revision}</TableCell>
<TableCell className="text-xs font-mono text-[var(--dg-text-muted)] max-w-[300px] truncate">{rev.image || rev.images?.[0] || '-'}</TableCell>
<TableCell>
{isLatest
? <Badge className="bg-[var(--dg-green)]/20 text-[var(--dg-green)] border-[var(--dg-green)]/30">当前</Badge>
: <Badge variant="outline" className="text-[var(--dg-text-muted)]">历史</Badge>}
</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)]">{formatK8sTime(rev.created_at || rev.time)}</TableCell>
<TableCell>
<Button variant="ghost" size="sm" className="h-7 text-amber-400" disabled={isLatest || rollbackLoading !== null} onClick={() => onRollback(rev)}>
{rollbackLoading === revision ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <RotateCcw className="h-3 w-3 mr-1" />}
回滚
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}