- unused-vars 全清零: 删除未用 import + 前缀 _ - catch (e: any) 94处移除显式标注 (strict=false 隐式推断) - Record<string,any>→Record<string,unknown> 安全替换168处 (无属性访问场景) - tsc -b 零错误, npm run build 通过 - 9/9 API 回归测试通过
736 lines
36 KiB
TypeScript
736 lines
36 KiB
TypeScript
// 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 '@/api';
|
||
import { useToast } from '@/hooks/use-toast';
|
||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||
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 confirm = useConfirm();
|
||
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;
|
||
const ok = await confirm({
|
||
title: '回滚版本',
|
||
description: `确认回滚到修订版本 #${revision}?`,
|
||
confirmText: '回滚',
|
||
variant: 'destructive',
|
||
});
|
||
if (!ok) return;
|
||
setRollbackLoading(revision);
|
||
try {
|
||
await kubernetesApi.rollbackDeployment(clusterCode, namespace, deployment.name, Number(revision));
|
||
toast({ title: '回滚已下发', description: `正在回滚到 #${revision}` });
|
||
onRefresh();
|
||
} catch (e) {
|
||
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) {
|
||
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>
|
||
);
|
||
}
|