Files
aiops/frontend/src/components/kubernetes/DeploymentView.tsx
T
attacker 5364d93ed7 fix(q1): 修复 tsc -b 构建中断 — 回退不安全的 unknown→any + 移除 Ansible 残留引用
- 781处 tsc -b 错误清零(unknown 属性访问/隐式any返回类型/类型转换不匹配)
- 移除 App.tsx 中已删除的 AnsibleManager/AnsibleWorkflow 导入和路由
- 修复 DeploymentView 中 _prefix 解构导致的属性不存在错误
- 修复 Header/Sidebar/PipelineEditor 中无效的类型断言
- asList() 返回类型 unknown[]→any[], 恢复 AgentMonitor 正常工作
- 放宽 noImplicitAny(临时), 待 types/ 定义完善后逐步恢复
- ESLint: unused=0, console=0, any=1041(Q1 后续降低)
- npm run build: ✅ 0 errors
2026-06-25 07:17:47 +08:00

728 lines
36 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 {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
import {
Clock,
HardDrive,
Network,
Activity,
RotateCcw,
Container,
History,
ChevronDown,
ChevronRight,
Terminal,
FileText,
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: Record<string, any>[] = (pod as Record<string, any>).conditions || (pod as Record<string, 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 Record<string, any>).startTime,
};
}
export function DeploymentView(props: DeploymentViewProps) {
const { clusterCode, namespace, deployment, pods, revisions, onRefresh } = 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('');
// HPA / CronHPA state
const [hpas, setHpas] = useState<any[]>([]);
const [cronHpas, setCronHpas] = useState<any[]>([]);
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]);
// Load HPA and CronHPA for "伸缩" tab
useEffect(() => {
if (tab !== 'scale') return;
setTabLoading('scale');
Promise.all([
kubernetesApi.getHPAs(clusterCode, namespace).catch(() => []),
Promise.resolve([]), // CronHPA 待后端支持
]).then(([hpaRes]) => {
const toArr = ( v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || []));
const allHpas = toArr(hpaRes);
const depName = deployment?.name || '';
setHpas(allHpas.filter(( h: any) => (h.scaleTargetRef?.name || h.targetRef?.name || '') === depName));
setCronHpas([]);
}).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>
))}
{/* 副本数控制 — Tab 行右侧 */}
<div className="ml-auto flex items-center gap-1 pr-2 shrink-0">
<Button variant="ghost" size="icon" className="h-7 w-7 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"
disabled={scaleLoading || scaleValue <= 0}
onClick={() => setScaleValue((v: number) => 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-16 h-7 text-center text-sm font-bold bg-slate-800 border-slate-700 text-slate-200"
/>
<Button variant="ghost" size="icon" className="h-7 w-7 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"
disabled={scaleLoading}
onClick={() => setScaleValue((v: number) => v + 1)}>
<Plus className="h-4 w-4" />
</Button>
{scaleValue !== total && (
<Button size="sm" className="h-7 text-xs bg-[var(--dg-blue)] hover:bg-[var(--dg-blue-hover)]"
disabled={scaleLoading} onClick={handleScale}>
{scaleLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : '应用'}
</Button>
)}
</div>
</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' && (
<HPACronHPATab
hpas={hpas}
cronHpas={cronHpas}
loading={tabLoading}
replicas={total}
/>
)}
{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, pods, onShowPodLogs, onShowPodTerminal, 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 Record<string, any>).containers && ((pod as Record<string, any>).containers as any[]).length > 0 && (
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-2">
{((pod as Record<string, 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 }: { 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: 伸缩 (HPA + CronHPA) ═══════════════
function HPACronHPATab({ hpas, cronHpas, loading, replicas }: { hpas: any[]; cronHpas: any[]; loading: string; replicas: number }) {
if (loading === 'scale') {
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-4">
{/* 当前副本数提示 */}
<div className="flex items-center gap-2 px-2">
<span className="text-sm text-[var(--dg-text-muted)]">当前副本:</span>
<span className="text-sm font-bold text-[var(--dg-text-primary)]">{replicas}</span>
<span className="text-xs text-[var(--dg-text-muted)]">(可通过 Tab 栏右侧 +/- 手动调整)</span>
</div>
{/* HPA 策略 */}
<Card className="dg-card">
<CardHeader className="pb-2"><CardTitle className="text-sm flex items-center gap-2"><Activity className="h-4 w-4 text-[var(--dg-orange)]" />HPA — 水平自动伸缩</CardTitle></CardHeader>
<CardContent>
{hpas.length === 0 ? (
<div className="text-sm text-[var(--dg-text-muted)] py-4">
未关联 HPA — <Button variant="link" className="h-auto p-0 text-[var(--dg-blue)] text-sm">创建 HPA</Button>
</div>
) : (
<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)]">指标</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">当前负载</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">状态</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{hpas.map(( hpa: any, i: number) => {
const minR = hpa.spec?.minReplicas ?? hpa.minReplicas ?? 1;
const maxR = hpa.spec?.maxReplicas ?? hpa.maxReplicas ?? 10;
const metrics = hpa.spec?.metrics || hpa.metrics || [];
const curR = hpa.status?.currentReplicas ?? hpa.currentReplicas ?? 0;
const conditions = hpa.status?.conditions || hpa.conditions || [];
const ableToScale = conditions.find(( c: any) => c.type === 'AbleToScale');
const ok = ableToScale?.status === 'True';
return (
<TableRow key={hpa.name || i} className="border-[var(--dg-border)]">
<TableCell className="font-medium text-[var(--dg-text-primary)]">{hpa.name || hpa.metadata?.name}</TableCell>
<TableCell className="text-sm text-[var(--dg-text-secondary)] font-mono">{minR} → {maxR}</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)]">
{metrics.map(( m: any, j: number) => {
const resource = m.resource || m.object?.metric?.name || '';
const target = m.resource?.target?.averageUtilization || m.resource?.target?.averageValue || m.resource?.target?.type || '-';
return <div key={j}>{resource?.name || resource}: {target}{m.resource?.target?.averageUtilization ? '%' : ''}</div>;
})}
</TableCell>
<TableCell className="text-sm font-mono text-[var(--dg-text-secondary)]">{curR}</TableCell>
<TableCell>
<Badge className={ok ? 'bg-[var(--dg-green)]/20 text-[var(--dg-green)]' : 'bg-[var(--dg-red)]/20 text-[var(--dg-red)]'}>
{ok ? '正常' : '异常'}
</Badge>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* CronHPA 策略 */}
<Card className="dg-card">
<CardHeader className="pb-2"><CardTitle className="text-sm flex items-center gap-2"><Clock className="h-4 w-4 text-[var(--dg-blue)]" />CronHPA — 定时伸缩</CardTitle></CardHeader>
<CardContent>
{cronHpas.length === 0 ? (
<div className="text-sm text-[var(--dg-text-muted)] py-4">
未关联 CronHPA — <Button variant="link" className="h-auto p-0 text-[var(--dg-blue)] text-sm">创建 CronHPA</Button>
</div>
) : (
<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)]">调度规则</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">时区</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">状态</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{cronHpas.map(( cron: any, i: number) => {
const jobs = cron.spec?.jobs || cron.jobs || [];
const tz = cron.spec?.timezone || 'UTC';
return (
<TableRow key={cron.name || i} className="border-[var(--dg-border)]">
<TableCell className="font-medium text-[var(--dg-text-primary)]">{cron.name || cron.metadata?.name}</TableCell>
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{
jobs.map(( j: any) => j.targetSize || j.targetReplicas || '?').join(', ')
}</TableCell>
<TableCell className="text-xs font-mono text-[var(--dg-text-muted)]">{jobs.map(( j: any) => j.schedule || '?').join(', ')}</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)]">{tz}</TableCell>
<TableCell>
<Badge className="bg-[var(--dg-green)]/20 text-[var(--dg-green)]">已启用</Badge>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
// ═══════════════ 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>
);
}