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
This commit is contained in:
attacker committed 2026-06-24 03:55:23 +00:00
1 parent 722587ab84
commit d581441fa8
5 files changed
+597 -391

No files matched your search

+1
View File
@@ -148,6 +148,7 @@ function App() {
{/* Quick Access Routes (redirect to first cluster) */}
<Route path="workloads" element={<LazyPage><NamespaceResourcePage /></LazyPage>} />
<Route path="workloads/:type" element={<LazyPage><NamespaceResourcePage /></LazyPage>} />
<Route path="nodes" element={<LazyPage><KubernetesNodes /></LazyPage>} />
<Route path="topology" element={<LazyPage><TopologyView /></LazyPage>} />
@@ -158,7 +158,15 @@ export function AIOpsAssistant() {
const [configReady, setConfigReady] = useState(false); // 已配置且启用
const [contextLoading, setContextLoading] = useState<string | null>(null); // 快捷操作拉取平台数据中
// 会话 ID:前端生成 UUID,用于隔离多轮对话 // Logan.Li
const [sessionId, setSessionId] = useState(() => crypto.randomUUID());
// crypto.randomUUID() 需要 HTTPS/secure context,HTTP 环境用 fallback
const generateUUID = (): string => {
try { return crypto.randomUUID(); } catch { /* HTTP fallback below */ }
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
};
const [sessionId, setSessionId] = useState(() => generateUUID());
const messagesEndRef = useRef<HTMLDivElement>(null);
// 滚动到底部 // Logan.Li
@@ -349,7 +357,7 @@ export function AIOpsAssistant() {
} catch {
// 清除失败不阻塞本地清空
}
const newSid = crypto.randomUUID();
const newSid = generateUUID();
setSessionId(newSid);
setMessages([]);
setInput('');
@@ -1,17 +1,22 @@
// DeploymentView.tsx — 增强只读详情看板(对标 Kuboard)
// DeploymentView.tsx — ACK 风格 Tab 详情看板
// Tabs: 容器组 | 访问方式 | 事件 | 容器伸缩 | 历史版本
// 作者: Logan <admin@attacker.club>
import { useState, Fragment } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
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, FileCode, Server, Container, History, ChevronDown, ChevronRight,
Terminal, FileText,
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';
@@ -19,17 +24,22 @@ import {
formatK8sTime, formatDuration, elapsedSeconds, statusColor, STATUS_COLORS,
} from './k8s-yaml-utils';
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; namespace?: string; replicas?: number; ready_replicas?: number; available_replicas?: number; image?: string; revision?: number; status?: string; created_at?: string; controlled?: boolean; owner_deployment?: string; }
// 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[];
clusterCode: string; namespace: string; deployment: any;
pods: PodItem[]; replicaSets: ReplicaSetItem[]; revisions: any[];
onRefresh: () => void;
onShowPodLogs: (pod: PodItem) => void;
onShowPodTerminal: (pod: PodItem) => void;
@@ -37,367 +47,537 @@ interface DeploymentViewProps {
onShowPodFileBrowser: (pod: PodItem) => void;
}
/** 计算 Pod 从调度到就绪的各阶段耗时(基于条件时间线,兼容扁平字段) */
// ─── 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 scheduled = find('PodScheduled') || {};
const initialized = find('Initialized') || {};
const ready = find('Ready') || {};
const containersReady = find('ContainersReady') || {};
const ts = (c: any) => c?.lastTransitionTime || c?.LastTransitionTime || c?.last_transition_time;
return {
scheduled: ts(scheduled),
initialized: ts(initialized),
containersReady: ts(containersReady),
ready: ts(ready),
scheduled: ts(find('PodScheduled') || {}),
initialized: ts(find('Initialized') || {}),
containersReady: ts(find('ContainersReady') || {}),
ready: ts(find('Ready') || {}),
startTime: pod.start_time || (pod as any).startTime,
};
}
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>
);
}
export function DeploymentView({
clusterCode, namespace, deployment, pods, replicaSets, revisions,
onRefresh, onShowPodLogs, onShowPodTerminal, onShowPodYaml, onShowPodFileBrowser,
}: DeploymentViewProps) {
const navigate = useNavigate();
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);
const ready = deployment.ready_replicas || deployment.readyReplicas || 0;
const total = deployment.replicas || 0;
const available = deployment.available_replicas || deployment.availableReplicas || 0;
const updated = deployment.updated_replicas || deployment.updatedReplicas || 0;
// 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;
const createdAt = deployment.created_at || deployment.creationTimestamp || deployment.created_at;
const updatedAt = deployment.updated_at || deployment.last_updated;
// 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}?\n镜像: ${rev.image || '-'}`)) return;
if (!confirm(`确认回滚到修订版本 #${revision}?`)) return;
setRollbackLoading(revision);
try {
await kubernetesApi.rollbackDeployment(clusterCode, namespace, deployment.name, Number(revision));
toast({ title: '回滚指令已下发', description: `正在回滚到 revision #${revision}` });
toast({ title: '回滚已下发', description: `正在回滚到 #${revision}` });
onRefresh();
} catch (e: any) {
toast({ title: '回滚失败', description: e?.message || String(e), variant: 'destructive' });
} finally { setRollbackLoading(null); }
};
const nodeClick = (nodeName: string | undefined) => {
if (!nodeName) return;
navigate(`/kubernetes/${clusterCode}`);
// 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">
{/* ============ 1. 状态总览 ============ */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
<StatCard label="副本就绪" value={`${ready}/${total}`} color={isHealthy ? STATUS_COLORS.green : STATUS_COLORS.orange} sub={isHealthy ? '全部就绪' : '部分未就绪'} />
<StatCard label="可用副本" value={available} color={STATUS_COLORS.blue} />
<StatCard label="已更新副本" value={updated} color={STATUS_COLORS.green} />
<StatCard label="策略" value={deployment.strategy || 'RollingUpdate'} color="#94a3b8" />
<StatCard label="创建时间" value={<span className="text-xs">{formatK8sTime(createdAt)}</span>} color="#94a3b8" />
<StatCard label="更新时间" value={<span className="text-xs">{formatK8sTime(updatedAt)}</span>} color="#94a3b8" />
{/* ============ 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>
{/* ============ 2. 容器详情(CPU/内存/镜像/端口) ============ */}
<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">
<ContainersTable deployment={deployment} />
</CardContent>
</Card>
{/* ============ 3. Pod 状态时间线 + 详情列表 ============ */}
<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 w-8"></TableHead>
<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>
<TableHead className="text-slate-400">启动时间</TableHead>
<TableHead className="text-slate-400">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pods.length === 0 ? (
<TableRow><TableCell colSpan={9} className="text-center text-slate-500 py-8">暂无 Pod</TableCell></TableRow>
) : pods.map((pod) => {
const tl = getPodTimeline(pod);
const schedToReady = elapsedSeconds(tl.scheduled, tl.ready);
const expanded = expandedPod === pod.name;
const nodeName = pod.node_name || pod.node;
return (
<Fragment key={pod.name}>
<TableRow className="border-slate-800 hover:bg-slate-800/30">
<TableCell>
<Button variant="ghost" size="icon" className="h-6 w-6 text-slate-500" onClick={() => setExpandedPod(expanded ? null : pod.name)}>
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</Button>
</TableCell>
<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>
<button className="text-sm text-blue-400 hover:underline inline-flex items-center gap-1" onClick={() => nodeClick(nodeName)} title="跳转节点详情">
<Server className="h-3 w-3" />{nodeName || '-'}
</button>
</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 className="text-xs text-slate-400">{formatDuration(schedToReady)}</TableCell>
<TableCell className="text-xs text-slate-500">{formatK8sTime(tl.startTime || pod.created_at)}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-blue-400 hover:text-blue-300 hover:bg-blue-500/10" onClick={() => onShowPodLogs(pod)}>
<FileText className="h-3.5 w-3.5 mr-1" />追踪日志
</Button>
<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={() => onShowPodTerminal(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={() => onShowPodFileBrowser(pod)}>
<HardDrive className="h-3.5 w-3.5 mr-1" />文件浏览器
</Button>
</div>
</TableCell>
</TableRow>
{expanded && (
<TableRow className="border-slate-800 bg-slate-950/40">
<TableCell colSpan={9} className="p-4">
<PodTimeline pod={pod} />
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
{/* ============ 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"><History className="h-4 w-4 text-purple-400" />历史版本(Revision / Rollout)</CardTitle>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">Revision</TableHead>
<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>
{revisions.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center text-slate-500 py-6 text-xs">后端 /revisions 接口尚未就绪,等待后端联调</TableCell></TableRow>
) : 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-slate-800 hover:bg-slate-800/30">
<TableCell className="font-mono text-slate-200">#{revision}</TableCell>
<TableCell className="text-xs font-mono text-slate-400 max-w-[280px] truncate">{rev.image || rev.images?.[0] || '-'}</TableCell>
<TableCell>
{isLatest
? <Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30">当前</Badge>
: <Badge className="bg-slate-700/40 text-slate-400 border-slate-600">历史</Badge>}
</TableCell>
<TableCell className="text-xs text-slate-500">{formatK8sTime(rev.created_at || rev.time)}</TableCell>
<TableCell>
<Button variant="ghost" size="sm" className="h-7 text-amber-400 hover:text-amber-300 disabled:opacity-40"
disabled={isLatest || rollbackLoading !== null}
onClick={() => handleRollback(rev)}>
{rollbackLoading === revision ? '回滚中...' : <><RotateCcw className="h-3 w-3 mr-1" />回滚</>}
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
{/* ============ 5. ReplicaSet 列表 ============ */}
<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-cyan-400" />ReplicaSet 列表 ({replicaSets.length})</CardTitle>
</CardHeader>
<CardContent className="p-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">Revision</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400">创建时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{replicaSets.length === 0 ? (
<TableRow><TableCell colSpan={6} className="text-center text-slate-500 py-6 text-xs">后端 /replicasets 接口尚未就绪,等待后端联调</TableCell></TableRow>
) : replicaSets.map((rs) => {
const r: any = rs;
const rsReady = r.ready_replicas ?? r.readyReplicas ?? 0;
const rsTotal = r.replicas ?? 0;
const ok = rsReady === rsTotal && rsTotal > 0;
return (
<TableRow key={r.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">
{r.name}
{r.controlled && <Badge variant="outline" className="ml-2 border-emerald-700 text-emerald-400 text-[10px]">当前</Badge>}
</TableCell>
<TableCell className="text-slate-300">{rsReady}/{rsTotal}</TableCell>
<TableCell className="text-xs font-mono text-slate-400 max-w-[240px] truncate">{r.image || '-'}</TableCell>
<TableCell className="text-xs text-slate-400 font-mono">#{r.revision || '-'}</TableCell>
<TableCell>
<Badge style={{ background: `${ok ? STATUS_COLORS.green : STATUS_COLORS.orange}22`, color: ok ? STATUS_COLORS.green : STATUS_COLORS.orange, border: `1px solid ${ok ? STATUS_COLORS.green : STATUS_COLORS.orange}44` }}>
{r.status || (ok ? 'Ready' : 'Updating')}
</Badge>
</TableCell>
<TableCell className="text-xs text-slate-500">{formatK8sTime(r.created_at)}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
{/* ============ 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>
);
}
// ============ 容器模板表(镜像/端口/资源/探针) ============
function ContainersTable({ deployment }: { deployment: any }) {
const containers: any[] = deployment.containers || deployment.container_specs || deployment.spec_containers || [];
if (containers.length === 0) {
return <p className="text-xs text-slate-500 py-3">无容器详情数据(扁平字段缺少 containers,可查看 YAML)</p>;
}
// ─── Info Item ───
function InfoItem({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="space-y-3">
{containers.map((c, idx) => {
const cpuReq = c.resources?.requests?.cpu || c.cpu_request;
const cpuLim = c.resources?.limits?.cpu || c.cpu_limit;
const memReq = c.resources?.requests?.memory || c.memory_request;
const memLim = c.resources?.limits?.memory || c.memory_limit;
const ports: any[] = c.ports || c.container_ports || [];
<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 (
<div key={idx} 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 || `container-${idx}`}</span>
</div>
<code className="text-[11px] text-slate-400 font-mono truncate max-w-[400px]">{c.image}</code>
<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>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
<div className="flex items-center gap-1.5 text-slate-400">
<Cpu className="h-3.5 w-3.5 text-amber-400" />
<span>CPU: <span className="text-slate-200 font-mono">{cpuReq || '-'}/{cpuLim || '-'}</span></span>
{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>
<div className="flex items-center gap-1.5 text-slate-400">
<MemoryStick className="h-3.5 w-3.5 text-purple-400" />
<span>内存: <span className="text-slate-200 font-mono">{memReq || '-'}/{memLim || '-'}</span></span>
</div>
<div className="flex items-center gap-1.5 text-slate-400">
<Network className="h-3.5 w-3.5 text-cyan-400" />
<span>端口: <span className="text-slate-200 font-mono">{ports.map((p: any) => typeof p === 'object' ? (p.containerPort || p.port) : p).join(', ') || '-'}</span></span>
</div>
<div className="flex items-center gap-1.5 text-slate-400">
<HardDrive className="h-3.5 w-3.5 text-emerald-400" />
<span>挂载: <span className="text-slate-200 font-mono">{(c.volume_mounts || c.volumeMounts || []).length || 0}</span></span>
</div>
</div>
{/* 探针 */}
<div className="mt-2 flex flex-wrap gap-3 text-[11px] text-slate-500">
{c.liveness_probe || c.livenessProbe ? (
<span className="text-emerald-500">● 存活探针</span>
) : <span className="text-slate-700">○ 无存活探针</span>}
{c.readiness_probe || c.readinessProbe ? (
<span className="text-blue-500">● 就绪探针</span>
) : <span className="text-slate-700">○ 无就绪探针</span>}
{c.startup_probe || c.startupProbe ? (
<span className="text-purple-500">● 启动探针</span>
) : null}
</div>
</div>
)}
</Fragment>
);
})}
</div>
);
}
// ============ Pod 状态时间线(调度→初始化→容器就绪→就绪) ============
function PodTimeline({ 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: 'Pod 就绪', ts: tl.ready, color: STATUS_COLORS.green },
];
// ═══════════════ 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>
<p className="text-xs text-slate-400 mb-2 flex items-center gap-1"><Clock className="h-3 w-3" />状态时间线</p>
<div className="flex items-center gap-1 overflow-x-auto pb-1">
{stages.map((s, i) => {
const prev = stages[i - 1];
const dur = elapsedSeconds(prev?.ts, s.ts);
return (
<div key={s.label} className="flex items-center">
<div className="flex flex-col items-center min-w-[80px]">
<div className="h-2.5 w-2.5 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-slate-600">{s.ts ? formatK8sTime(s.ts) : '-'}</span>
<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>
{i < stages.length - 1 && (
<div className="flex flex-col items-center mx-1 min-w-[40px]">
<div className="h-0.5 w-10" style={{ background: '#334155' }} />
<span className="text-[9px] text-slate-600 mt-0.5">{dur != null ? formatDuration(dur) : ''}</span>
{/* 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>
)}
</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>
);
}
@@ -52,6 +52,7 @@ type ActionModal =
| { kind: 'podLogs'; pod: string }
| { kind: 'podYaml'; pod: string }
| { kind: 'delete' }
| { kind: 'cicd' }
| null;
export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mode, onBack, onModeChange }: DeploymentWorkloadProps) {
@@ -194,8 +195,8 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
};
const handleCICD = () => {
// 跳转到流水线页面,传递 namespace + deployment 上下文
navigate(`/pipelines?namespace=${encodeURIComponent(namespace)}&app=${encodeURIComponent(deploymentName)}`);
// 打开 Webhook 弹窗(不跳转流水线)
setModal({ kind: 'cicd' as any });
};
const handleUpdateImage = async () => {
@@ -327,68 +328,35 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
<RefreshCw className="h-4 w-4" />
</Button>
{/* 顶部操作菜单(Kuboard 风格下拉) */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="border-slate-700 text-slate-200">
操作 <ChevronDown className="h-3.5 w-3.5 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52 bg-slate-900 border-slate-700">
<DropdownMenuItem className="text-slate-200 hover:bg-slate-800 cursor-pointer" onClick={() => onModeChange('edit')}>
<Pencil className="h-4 w-4 mr-2 text-blue-400" /> 编辑
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-200 hover:bg-slate-800 cursor-pointer" onClick={() => setModal({ kind: 'yaml' })}>
<FileCode className="h-4 w-4 mr-2 text-emerald-400" /> 预览 / 编辑 YAML
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-200 hover:bg-slate-800 cursor-pointer" onClick={() => setModal({ kind: 'image' })}>
<ImageIcon className="h-4 w-4 mr-2 text-purple-400" /> 调整镜像版本
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-200 hover:bg-slate-800 cursor-pointer" onClick={() => { setScaleValue(deployment.replicas || 0); setModal({ kind: 'scale' }); }}>
<Minus className="h-4 w-4 mr-2 text-amber-400" /> 伸缩
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-200 hover:bg-slate-800 cursor-pointer" onClick={() => toast({ title: '日志 / 终端', description: '请在 Pod 列表中点击对应按钮' })}>
<Terminal className="h-4 w-4 mr-2 text-cyan-400" /> 日志 / 终端
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-200 hover:bg-slate-800 cursor-pointer" onClick={handleRestart}>
<RotateCw className="h-4 w-4 mr-2 text-blue-400" /> 重启
</DropdownMenuItem>
<DropdownMenuItem
className="text-slate-200 hover:bg-slate-800 cursor-pointer"
disabled={!!actionLoading}
onClick={handleTogglePause}
>
{deployment?.paused
? <><Play className="h-4 w-4 mr-2 text-green-400" /> 恢复更新</>
: <><Pause className="h-4 w-4 mr-2 text-orange-400" /> 暂停更新</>
}
</DropdownMenuItem>
<DropdownMenuItem
className="text-slate-200 hover:bg-slate-800 cursor-pointer"
onClick={handleCICD}
>
<Rocket className="h-4 w-4 mr-2 text-purple-400" /> CI/CD 发布
</DropdownMenuItem>
<DropdownMenuItem className="text-red-400 hover:bg-slate-800 cursor-pointer" onClick={() => setModal({ kind: 'delete' })}>
<Trash2 className="h-4 w-4 mr-2" /> 删除
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-slate-800" />
<DropdownMenuItem className="text-slate-300 hover:bg-slate-800 cursor-pointer" onClick={() => toast({ title: '元数据', description: 'Labels / Annotations / Selectors,集成在 YAML 视图中' })}>
<Info className="h-4 w-4 mr-2 text-slate-400" /> 元数据
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-300 hover:bg-slate-800 cursor-pointer" onClick={() => toast({ title: '运行时', description: 'Pod 运行状态请见 Pod 实例区' })}>
<Cpu className="h-4 w-4 mr-2 text-slate-400" /> 运行时
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-300 hover:bg-slate-800 cursor-pointer"
onClick={() => navigate(`${viewBase}/view/Service?selector=${encodeURIComponent(JSON.stringify(deployment?.spec?.selector?.matchLabels || deployment?.spec?.template?.metadata?.labels || {}))}`)}>
<Network className="h-4 w-4 mr-2 text-slate-400" /> 服务
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-300 hover:bg-slate-800 cursor-pointer"
onClick={() => navigate(`${viewBase}/view/Ingress?namespace=${namespace}`)}>
<GitBranch className="h-4 w-4 mr-2 text-slate-400" /> 应用路由
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* 操作按钮行 */}
<div className="flex items-center gap-1.5">
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300" onClick={() => onModeChange('edit')}>
<Pencil className="h-3.5 w-3.5 mr-1" />编辑
</Button>
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300" onClick={() => setModal({ kind: 'image' })}>
<ImageIcon className="h-3.5 w-3.5 mr-1" />调整镜像
</Button>
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300" onClick={handleRestart}>
<RotateCw className="h-3.5 w-3.5 mr-1" />重启
</Button>
<Button
variant="outline" size="sm"
className="border-slate-700 text-slate-300"
disabled={!!actionLoading}
onClick={handleTogglePause}
>
{deployment?.paused
? <><Play className="h-3.5 w-3.5 mr-1 text-green-400" />恢复</>
: <><Pause className="h-3.5 w-3.5 mr-1 text-orange-400" />暂停</>
}
</Button>
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300" onClick={handleCICD}>
<Rocket className="h-3.5 w-3.5 mr-1 text-purple-400" />CI/CD
</Button>
<Button variant="outline" size="sm" className="border-slate-700 text-red-400 hover:bg-red-500/10" onClick={() => setModal({ kind: 'delete' })}>
<Trash2 className="h-3.5 w-3.5 mr-1" />删除
</Button>
</div>
</div>
</div>
@@ -521,6 +489,13 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
</ModalShell>
)}
{/* ============ CI/CD Webhook 弹窗 ============ */}
{modal?.kind === 'cicd' && (
<ModalShell title="CI/CD Webhook" onClose={() => setModal(null)}>
<CICDWebhookContent clusterCode={clusterCode} namespace={namespace} deploymentName={deploymentName} />
</ModalShell>
)}
{/* ============ Pod 日志 ============ */}
{modal?.kind === 'podLogs' && (
<PodLogsModal
@@ -661,6 +636,45 @@ function EditModeContainer({ clusterCode, namespace, deploymentName, manifest, o
);
}
// ============ CI/CD Webhook 内容 ============
function CICDWebhookContent({ clusterCode, namespace, deploymentName }: { clusterCode: string; namespace: string; deploymentName: string }) {
const { toast } = useToast();
const token = useUserStore((s: any) => s.token) || localStorage.getItem('token') || '';
const webhookUrl = `${window.location.origin}/api/v1/clusters/${clusterCode}/namespaces/${namespace}/deployments/${deploymentName}/update-image`;
const curlCmd = `curl -X POST '${webhookUrl}' \\
-H 'Authorization: Bearer ${token ? token.substring(0, 16) + '...' : 'YOUR_TOKEN'}' \\
-H 'Content-Type: application/json' \\
-d '{"image": "harbor.example.com/app:v1.0", "container": "your-container-name"}'`;
const copy = (text: string) => {
navigator.clipboard.writeText(text).then(() => toast({ title: '已复制' })).catch(() => {});
};
return (
<div className="space-y-4">
<div>
<Label className="text-slate-400 text-sm">Webhook URL</Label>
<div className="flex items-center gap-2 mt-1">
<code className="flex-1 text-xs text-slate-300 bg-slate-800 rounded px-3 py-2 break-all font-mono">{webhookUrl}</code>
<Button variant="outline" size="sm" className="border-slate-700 text-slate-400 shrink-0" onClick={() => copy(webhookUrl)}>复制</Button>
</div>
</div>
<div>
<Label className="text-slate-400 text-sm">curl 示例</Label>
<div className="relative mt-1">
<pre className="text-xs text-slate-300 bg-slate-800 rounded p-3 overflow-x-auto font-mono whitespace-pre-wrap">{curlCmd}</pre>
<Button variant="outline" size="sm" className="absolute top-2 right-2 border-slate-700 text-slate-400" onClick={() => copy(curlCmd)}>复制</Button>
</div>
</div>
<div className="text-xs text-slate-500 space-y-1">
<p>• 使用 Bearer Token 认证(JWT 登录令牌)</p>
<p>• <code className="text-slate-400">image</code> 必填,指定新镜像地址</p>
<p>• <code className="text-slate-400">container</code> 可选,不填则更新第一个容器</p>
</div>
</div>
);
}
// ============ 通用 Modal 外壳 ============
function ModalShell({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {
return (