对齐 Vue 后台惯例: - src/api/ 域 API(原 services/api) - src/utils/request.ts HTTP 封装(原 services/http.ts) - src/utils/export.ts 客户端导出工具 - src/utils/index.ts 工具统一出口 全局导入改为 @/api 与 @/utils/request,tsc + build 通过。
789 lines
34 KiB
TypeScript
789 lines
34 KiB
TypeScript
// DeploymentWorkload.tsx — Kuboard 风格 Deployment 详情主组件
|
||
// - 顶部操作菜单: 编辑 | 预览/编辑YAML | 调整镜像版本 | 伸缩 | 日志/终端 | 重启 | 暂停/恢复更新 | CI/CD发布 | 删除 | 元数据 | 运行时 | 服务 | 应用路由
|
||
// - 三模式: view(只读增强看板)/ edit(可视化编辑器)/ yaml(YAML编辑器)
|
||
// 作者: Logan <admin@attacker.club>
|
||
import { useState, useEffect, useCallback } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { Card, CardContent } from '@/components/ui/card';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Label } from '@/components/ui/label';
|
||
import { Badge } from '@/components/ui/badge';
|
||
|
||
import {
|
||
|
||
ArrowLeft,
|
||
Image as ImageIcon,
|
||
Minus,
|
||
Plus,
|
||
RotateCw,
|
||
Trash2,
|
||
FileText,
|
||
RefreshCw,
|
||
Loader2,
|
||
X,
|
||
Network,
|
||
CheckCircle,
|
||
AlertTriangle,
|
||
XCircle,
|
||
Pause,
|
||
Play,
|
||
Rocket,
|
||
Container,
|
||
Settings,
|
||
Database,
|
||
} from 'lucide-react';
|
||
|
||
import { kubernetesApi } from '@/api';
|
||
import { useToast } from '@/hooks/use-toast';
|
||
import { useUserStore } from '@/store';
|
||
import { YamlViewer } from './YamlViewer';
|
||
import { DeploymentView } from './DeploymentView';
|
||
import { DeploymentEditContainers } from './DeploymentEditContainers';
|
||
import { DeploymentEditBasic } from './DeploymentEditBasic';
|
||
import { DeploymentEditStorage } from './DeploymentEditStorage';
|
||
import { DeploymentEditAdvanced } from './DeploymentEditAdvanced';
|
||
import { DeploymentEditService } from './DeploymentEditService';
|
||
import { PodExecTerminal } from './PodExecTerminal';
|
||
import { PodFileBrowser } from './PodFileBrowser';
|
||
|
||
type WorkloadMode = 'view' | 'edit' | 'yaml';
|
||
|
||
export interface DeploymentWorkloadProps {
|
||
clusterCode: string;
|
||
namespace: string;
|
||
deploymentName: string;
|
||
/** 'view' | 'edit' | 'yaml' — 来自路由参数 */
|
||
mode: WorkloadMode;
|
||
onBack: () => void;
|
||
/** 跳转到另一模式(用于"编辑"按钮、保存成功后切换) */
|
||
onModeChange: (mode: WorkloadMode) => void;
|
||
}
|
||
|
||
type ActionModal =
|
||
| { kind: 'yaml' }
|
||
| { kind: 'image' }
|
||
| { kind: 'scale' }
|
||
| { kind: 'podLogs'; pod: string }
|
||
| { kind: 'podYaml'; pod: string }
|
||
| { kind: 'delete' }
|
||
| { kind: 'cicd' }
|
||
| null;
|
||
|
||
export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mode, onBack, onModeChange }: DeploymentWorkloadProps) {
|
||
const navigate = useNavigate();
|
||
const { toast } = useToast();
|
||
const [deployment, setDeployment] = useState<any>(null);
|
||
const [manifest, setManifest] = useState<any>(null);
|
||
const [pods, setPods] = useState<any[]>([]);
|
||
const [replicaSets, setReplicaSets] = useState<any[]>([]);
|
||
const [revisions, setRevisions] = useState<any[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [actionLoading, setActionLoading] = useState('');
|
||
const [modal, setModal] = useState<ActionModal>(null);
|
||
// 调整镜像版本
|
||
const [newImage, setNewImage] = useState('');
|
||
// 伸缩
|
||
const [scaleValue, setScaleValue] = useState(0);
|
||
const [terminalPod, setTerminalPod] = useState<string | null>(null);
|
||
const [fileBrowserPod, setFileBrowserPod] = useState<string | null>(null);
|
||
const clusterId = deployment?.cluster_id || deployment?.clusterId || clusterCode;
|
||
|
||
const toArray = ( v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || []));
|
||
|
||
const loadData = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const [dep, podRes, rsRes, revRes] = await Promise.all([
|
||
kubernetesApi.getDeployment(clusterCode, namespace, deploymentName).catch(() => null),
|
||
kubernetesApi.getNamespacePods(clusterCode, namespace).catch(() => []),
|
||
kubernetesApi.getReplicaSets(clusterCode, namespace).catch(() => []),
|
||
kubernetesApi.getDeploymentRevisions(clusterCode, namespace, deploymentName).catch(() => []),
|
||
]);
|
||
const depData = dep || (Array.isArray(dep) ? dep[0] : null);
|
||
setDeployment(depData);
|
||
setScaleValue(depData?.replicas || 0);
|
||
setNewImage(depData?.image || '');
|
||
|
||
const allPods = toArray(podRes);
|
||
// 过滤当前 Deployment 的 Pod(按名称前缀,兼容 ownerReference 不可用场景)
|
||
const depPods = allPods.filter(( p: any) =>
|
||
p.namespace === namespace && (p.name || '').startsWith(deploymentName)
|
||
);
|
||
setPods(depPods);
|
||
|
||
const allRs = toArray(rsRes);
|
||
const depRs = allRs.filter(( rs: any) =>
|
||
(rs.namespace === namespace) &&
|
||
((rs.owner_deployment === deploymentName) || (rs.name || '').startsWith(deploymentName))
|
||
);
|
||
setReplicaSets(depRs);
|
||
|
||
setRevisions(toArray(revRes));
|
||
} catch (e) {
|
||
console.error('加载详情失败:', e);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [clusterCode, namespace, deploymentName]);
|
||
|
||
// 加载 manifest(用于 edit 模式 + YAML 编辑)
|
||
const loadManifest = useCallback(async () => {
|
||
try {
|
||
const yamlText = await kubernetesApi.getResourceYaml(clusterCode, namespace, 'deployments', deploymentName);
|
||
// 简易 YAML→JSON:交给浏览器原生 JSON.parse 兜底(K8s API 多以 JSON 返回);
|
||
// 若是纯 YAML,则尝试用 js-yaml(如项目已安装),否则保留为 null 让编辑模式降级。
|
||
let parsed: Record<string, any> = null;
|
||
try { parsed = JSON.parse(yamlText); } catch {
|
||
// 非纯 JSON,尝试动态加载 js-yaml
|
||
try {
|
||
const jsyaml: Record<string, any> = await import('js-yaml');
|
||
parsed = jsyaml.load(yamlText);
|
||
} catch {
|
||
parsed = null;
|
||
}
|
||
}
|
||
setManifest(parsed || deployment);
|
||
} catch (e) {
|
||
console.warn('加载 manifest 失败(编辑功能降级):', e);
|
||
setManifest(deployment);
|
||
}
|
||
}, [clusterCode, namespace, deploymentName, deployment]);
|
||
|
||
useEffect(() => { loadData(); }, [loadData]);
|
||
useEffect(() => { if (mode === 'edit' && !manifest) loadManifest(); }, [mode, manifest, loadManifest]);
|
||
|
||
// ============ 操作 ============
|
||
const _handleScale = async (delta: number) => {
|
||
const newReplicas = (deployment?.replicas || 0) + delta;
|
||
if (newReplicas < 0) return;
|
||
setActionLoading('scale');
|
||
try {
|
||
await kubernetesApi.scaleDeployment(clusterCode, namespace, deploymentName, newReplicas);
|
||
toast({ title: '扩缩容成功', description: `副本数已设置为 ${newReplicas}` });
|
||
loadData();
|
||
} catch (e: any) {
|
||
toast({ title: '操作失败', description: e?.message || String(e), variant: 'destructive' });
|
||
} finally { setActionLoading(''); }
|
||
};
|
||
|
||
const handleScaleTo = async () => {
|
||
setActionLoading('scale');
|
||
try {
|
||
await kubernetesApi.scaleDeployment(clusterCode, namespace, deploymentName, scaleValue);
|
||
toast({ title: '伸缩成功', description: `副本数已设置为 ${scaleValue}` });
|
||
setModal(null);
|
||
loadData();
|
||
} catch (e: any) {
|
||
toast({ title: '操作失败', description: e?.message || String(e), variant: 'destructive' });
|
||
} finally { setActionLoading(''); }
|
||
};
|
||
|
||
const handleRestart = async () => {
|
||
if (!confirm(`确认滚动重启 Deployment "${deploymentName}"?`)) return;
|
||
setActionLoading('restart');
|
||
try {
|
||
await kubernetesApi.restartDeployment(clusterCode, namespace, deploymentName);
|
||
toast({ title: '重启指令已下发', description: 'Deployment 正在滚动重启' });
|
||
setTimeout(loadData, 1500);
|
||
} catch (e: any) {
|
||
toast({ title: '重启失败', description: e?.message || String(e), variant: 'destructive' });
|
||
} finally { setActionLoading(''); }
|
||
};
|
||
|
||
const handleTogglePause = async () => {
|
||
const isPaused = !!deployment?.paused;
|
||
const action = isPaused ? '恢复' : '暂停';
|
||
if (!confirm(`确认${action} Deployment "${deploymentName}" 的滚动更新?`)) return;
|
||
setActionLoading(isPaused ? 'resume' : 'pause');
|
||
try {
|
||
if (isPaused) {
|
||
await kubernetesApi.resumeDeployment(clusterCode, namespace, deploymentName);
|
||
} else {
|
||
await kubernetesApi.pauseDeployment(clusterCode, namespace, deploymentName);
|
||
}
|
||
toast({ title: `${action}成功`, description: `Deployment 滚动更新已${action}` });
|
||
setTimeout(loadData, 1000);
|
||
} catch (e: any) {
|
||
toast({ title: `${action}失败`, description: e?.message || String(e), variant: 'destructive' });
|
||
} finally { setActionLoading(''); }
|
||
};
|
||
|
||
const handleCICD = () => {
|
||
// 打开 Webhook 弹窗(不跳转流水线)
|
||
setModal({ kind: 'cicd' });
|
||
};
|
||
|
||
const handleUpdateImage = async () => {
|
||
if (!newImage.trim()) return;
|
||
setActionLoading('image');
|
||
try {
|
||
await kubernetesApi.updateDeploymentImage(clusterCode, namespace, deploymentName, newImage);
|
||
toast({ title: '镜像更新成功', description: `正在滚动更新为 ${newImage}` });
|
||
setModal(null);
|
||
loadData();
|
||
} catch (e: any) {
|
||
toast({ title: '更新失败', description: e?.message || String(e), variant: 'destructive' });
|
||
} finally { setActionLoading(''); }
|
||
};
|
||
|
||
const handleDelete = async () => {
|
||
setActionLoading('delete');
|
||
try {
|
||
// 复用通用删除:通过 PUT 空文档或调用隐式端点。若无专用端点,则提示。
|
||
const tk = useUserStore.getState().token || '';
|
||
const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
|
||
const url = `${baseUrl}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${deploymentName}`;
|
||
const resp = await fetch(url, { method: 'DELETE', headers: { Authorization: `Bearer ${tk}` } });
|
||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||
toast({ title: '删除成功', description: `${deploymentName} 已删除` });
|
||
setModal(null);
|
||
setTimeout(() => navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments`), 800);
|
||
} catch (e: any) {
|
||
toast({ title: '删除失败', description: e?.message || String(e), variant: 'destructive' });
|
||
} finally { setActionLoading(''); }
|
||
};
|
||
|
||
// 日志流(Pod 实时日志)
|
||
const handleShowPodLogs = ( pod: any) => setModal({ kind: 'podLogs', pod: pod.name });
|
||
const handleShowPodYaml = ( pod: any) => setModal({ kind: 'podYaml', pod: pod.name });
|
||
const handleShowPodTerminal = ( pod: any) => {
|
||
setTerminalPod(pod.name);
|
||
};
|
||
const handleShowPodFileBrowser = ( pod: any) => {
|
||
setFileBrowserPod(pod.name);
|
||
};
|
||
|
||
// ============ 渲染 ============
|
||
if (loading) {
|
||
return (
|
||
<Card className="bg-slate-900 border-slate-800">
|
||
<CardContent className="p-12 text-center">
|
||
<Loader2 className="h-6 w-6 animate-spin mx-auto text-slate-500" />
|
||
<p className="text-slate-500 mt-3">加载中...</p>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
if (!deployment) {
|
||
return (
|
||
<Card className="bg-slate-900 border-slate-800">
|
||
<CardContent className="p-12 text-center">
|
||
<XCircle className="h-8 w-8 mx-auto text-red-400" />
|
||
<p className="text-slate-400 mt-3">未找到 Deployment: {deploymentName}</p>
|
||
<Button variant="outline" className="mt-4" onClick={onBack}>返回</Button>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
const ready = deployment.ready_replicas || deployment.readyReplicas || 0;
|
||
const total = deployment.replicas || 0;
|
||
const isHealthy = ready === total && total > 0;
|
||
const _viewBase = `/kubernetes/${clusterCode}/namespace/${namespace}/workload`;
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* ============ 顶部:标题 + 模式标识 + 操作菜单 ============ */}
|
||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||
<div className="flex items-center gap-3 min-w-0">
|
||
<Button variant="ghost" size="sm" className="text-slate-400" onClick={onBack}>
|
||
<ArrowLeft className="h-4 w-4 mr-1" />返回
|
||
</Button>
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<h1 className="text-xl font-bold text-slate-100 truncate">{deploymentName}</h1>
|
||
<Badge variant="outline" className="border-slate-700 text-slate-400">{namespace}</Badge>
|
||
<Badge variant="outline" className="border-slate-700 text-slate-400">Deployment</Badge>
|
||
<Badge
|
||
className={isHealthy
|
||
? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30'
|
||
: 'bg-amber-500/20 text-amber-400 border-amber-500/30'}
|
||
>
|
||
{isHealthy ? <CheckCircle className="h-3 w-3 mr-1" /> : <AlertTriangle className="h-3 w-3 mr-1" />}
|
||
{ready}/{total}
|
||
</Badge>
|
||
{deployment?.paused && (
|
||
<Badge className="bg-orange-500/20 text-orange-400 border-orange-500/30">
|
||
<Pause className="h-3 w-3 mr-1" />已暂停更新
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
<p className="text-xs text-slate-500 mt-0.5 truncate">
|
||
镜像: <code className="text-slate-400">{deployment.image || '-'}</code>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
{/* 模式切换 — 查看 / 编辑 / YAML */}
|
||
<div className="inline-flex rounded-md border border-slate-700 overflow-hidden">
|
||
<button
|
||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${mode === 'view' ? 'bg-slate-700 text-slate-100' : 'bg-transparent text-slate-400 hover:text-slate-200'}`}
|
||
onClick={() => onModeChange('view')}
|
||
>
|
||
查看
|
||
</button>
|
||
<button
|
||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${mode === 'edit' ? 'bg-blue-600 text-white' : 'bg-transparent text-slate-400 hover:text-slate-200'}`}
|
||
onClick={() => onModeChange('edit')}
|
||
>
|
||
编辑
|
||
</button>
|
||
<button
|
||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${mode === 'yaml' ? 'bg-emerald-600 text-white' : 'bg-transparent text-slate-400 hover:text-slate-200'}`}
|
||
onClick={() => onModeChange('yaml')}
|
||
>
|
||
YAML
|
||
</button>
|
||
</div>
|
||
|
||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-400" onClick={loadData} title="刷新">
|
||
<RefreshCw className="h-4 w-4" />
|
||
</Button>
|
||
|
||
{/* 操作按钮行 */}
|
||
<div className="flex items-center gap-1.5">
|
||
<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>
|
||
|
||
{/* ============ 主体:view / edit / yaml ============ */}
|
||
{mode === 'view' ? (
|
||
<DeploymentView
|
||
clusterCode={clusterCode}
|
||
namespace={namespace}
|
||
deployment={deployment}
|
||
pods={pods}
|
||
replicaSets={replicaSets}
|
||
revisions={revisions}
|
||
onRefresh={loadData}
|
||
onShowPodLogs={handleShowPodLogs}
|
||
onShowPodTerminal={handleShowPodTerminal}
|
||
onShowPodYaml={handleShowPodYaml}
|
||
onShowPodFileBrowser={handleShowPodFileBrowser}
|
||
/>
|
||
) : mode === 'yaml' ? (
|
||
/* YAML 编辑模式 — 内联 CodeMirror 编辑器 */
|
||
<YamlViewer
|
||
clusterCode={clusterCode}
|
||
namespace={namespace}
|
||
resource="deployments"
|
||
name={deploymentName}
|
||
cleanBeforeEdit
|
||
inline
|
||
onSaved={loadData}
|
||
onClose={() => onModeChange('view')}
|
||
/>
|
||
) : (
|
||
/* 可视化编辑模式 — Tab 系统 */
|
||
<EditModeContainer
|
||
clusterCode={clusterCode}
|
||
namespace={namespace}
|
||
deploymentName={deploymentName}
|
||
manifest={manifest}
|
||
onSaved={() => { loadManifest(); loadData(); onModeChange('view'); }}
|
||
onCancel={() => onModeChange('view')}
|
||
/>
|
||
)}
|
||
|
||
{/* ============ 弹窗:YAML ============ */}
|
||
{modal?.kind === 'yaml' && (
|
||
<YamlViewer
|
||
clusterCode={clusterCode}
|
||
namespace={namespace}
|
||
resource="deployments"
|
||
name={deploymentName}
|
||
cleanBeforeEdit
|
||
onClose={() => setModal(null)}
|
||
onSaved={loadData}
|
||
/>
|
||
)}
|
||
|
||
{/* ============ 弹窗:调整镜像版本 ============ */}
|
||
{modal?.kind === 'image' && (
|
||
<ModalShell title="调整镜像版本" onClose={() => setModal(null)}>
|
||
<div className="space-y-3">
|
||
<div>
|
||
<Label className="text-slate-400 text-sm">当前镜像</Label>
|
||
<p className="text-xs text-slate-500 font-mono mt-1 bg-slate-800 rounded px-3 py-2">{deployment.image || '-'}</p>
|
||
</div>
|
||
<div>
|
||
<Label className="text-slate-400 text-sm">新镜像地址</Label>
|
||
<Input value={newImage} onChange={e => setNewImage(e.target.value)}
|
||
placeholder="harbor.example.com/app:v2.0"
|
||
className="mt-1 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" />
|
||
</div>
|
||
<div className="flex justify-end gap-2 pt-2">
|
||
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={() => setModal(null)}>取消</Button>
|
||
<Button className="bg-blue-600 hover:bg-blue-700" disabled={actionLoading === 'image' || !newImage.trim()} onClick={handleUpdateImage}>
|
||
{actionLoading === 'image' ? <Loader2 className="h-4 w-4 animate-spin" /> : '更新镜像'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</ModalShell>
|
||
)}
|
||
|
||
{/* ============ 弹窗:伸缩 ============ */}
|
||
{modal?.kind === 'scale' && (
|
||
<ModalShell title="伸缩 (Scale)" onClose={() => setModal(null)}>
|
||
<div className="space-y-3">
|
||
<p className="text-xs text-slate-500">当前副本: {deployment.replicas || 0}</p>
|
||
<div className="flex items-center justify-center gap-3 py-3">
|
||
<Button variant="outline" size="icon" className="border-slate-700 text-slate-300"
|
||
disabled={actionLoading === 'scale' || scaleValue <= 0}
|
||
onClick={() => setScaleValue(v => 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-24 text-center text-lg font-bold bg-slate-800 border-slate-700 text-slate-200" />
|
||
<Button variant="outline" size="icon" className="border-slate-700 text-slate-300"
|
||
disabled={actionLoading === 'scale'}
|
||
onClick={() => setScaleValue(v => v + 1)}>
|
||
<Plus className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
<div className="flex justify-end gap-2 pt-2">
|
||
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={() => setModal(null)}>取消</Button>
|
||
<Button className="bg-blue-600 hover:bg-blue-700" disabled={actionLoading === 'scale'} onClick={handleScaleTo}>
|
||
{actionLoading === 'scale' ? <Loader2 className="h-4 w-4 animate-spin" /> : '确认'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</ModalShell>
|
||
)}
|
||
|
||
{/* ============ 弹窗:删除 ============ */}
|
||
{modal?.kind === 'delete' && (
|
||
<ModalShell title="删除 Deployment" onClose={() => setModal(null)}>
|
||
<div className="space-y-3">
|
||
<div className="flex items-center gap-2 text-red-400">
|
||
<AlertTriangle className="h-5 w-5" />
|
||
<span className="font-semibold">此操作不可撤销</span>
|
||
</div>
|
||
<p className="text-sm text-slate-400">
|
||
确认删除 Deployment <code className="text-slate-200 font-mono">{namespace}/{deploymentName}</code>?
|
||
其管理的所有 Pod 将被一并回收。
|
||
</p>
|
||
<div className="flex justify-end gap-2 pt-2">
|
||
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={() => setModal(null)}>取消</Button>
|
||
<Button variant="destructive" disabled={actionLoading === 'delete'} onClick={handleDelete}>
|
||
{actionLoading === 'delete' ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Trash2 className="h-4 w-4 mr-1" />}
|
||
确认删除
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</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
|
||
clusterCode={clusterCode}
|
||
namespace={namespace}
|
||
podName={modal.pod}
|
||
containers={(pods.find(p => p.name === modal.pod)?.containers || []).map(( c: any) => c.name).filter(Boolean)}
|
||
onClose={() => setModal(null)}
|
||
/>
|
||
)}
|
||
|
||
{/* ============ Pod 终端 ============ */}
|
||
<PodExecTerminal
|
||
open={!!terminalPod}
|
||
onOpenChange={(open) => { if (!open) setTerminalPod(null); }}
|
||
clusterId={clusterId}
|
||
namespace={namespace}
|
||
podName={terminalPod || ''}
|
||
containers={(pods.find(( p: any) => p.name === terminalPod)?.containers || []).map(( c: any) => c.name).filter(Boolean)}
|
||
/>
|
||
{/* ============ Pod 文件浏览器 ============ */}
|
||
<PodFileBrowser
|
||
open={!!fileBrowserPod}
|
||
onOpenChange={(open) => { if (!open) setFileBrowserPod(null); }}
|
||
clusterCode={clusterCode}
|
||
namespace={namespace}
|
||
podName={fileBrowserPod || ''}
|
||
containers={(pods.find(( p: any) => p.name === fileBrowserPod)?.containers || []).map(( c: any) => c.name).filter(Boolean)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ============ 编辑模式 Tab 容器(M6: 基本信息Tab + 容器编辑预留) ============
|
||
function EditModeContainer({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: {
|
||
clusterCode: string;
|
||
namespace: string;
|
||
deploymentName: string;
|
||
manifest: any | null;
|
||
onSaved: () => void;
|
||
onCancel: () => void;
|
||
}) {
|
||
type EditTab = 'basic' | 'containers' | 'storage' | 'advanced' | 'service';
|
||
const [activeTab, setActiveTab] = useState<EditTab>('basic');
|
||
|
||
const tabs: Array<{ key: EditTab; label: string; icon: typeof Settings; available: boolean; badge?: string }> = [
|
||
{ key: 'basic', label: '基本信息', icon: Settings, available: true },
|
||
{ key: 'containers', label: '容器信息', icon: Container, available: true },
|
||
{ key: 'storage', label: '存储挂载', icon: Database, available: true },
|
||
{ key: 'advanced', label: '高级设置', icon: Settings, available: true },
|
||
{ key: 'service', label: '服务/应用路由', icon: Network, available: true },
|
||
];
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* Tab 导航 */}
|
||
<div className="flex items-center gap-1 border-b border-slate-800 pb-px overflow-x-auto">
|
||
{tabs.map(tab => {
|
||
const active = activeTab === tab.key;
|
||
const Icon = tab.icon;
|
||
return (
|
||
<button
|
||
key={tab.key}
|
||
disabled={!tab.available}
|
||
onClick={() => tab.available && setActiveTab(tab.key)}
|
||
className={`flex items-center gap-1.5 px-3 py-2 text-xs font-medium border-b-2 transition-colors whitespace-nowrap
|
||
${active
|
||
? 'border-blue-500 text-blue-400'
|
||
: tab.available
|
||
? 'border-transparent text-slate-400 hover:text-slate-200'
|
||
: 'border-transparent text-slate-600 cursor-not-allowed'
|
||
}`}
|
||
>
|
||
<Icon className="h-3.5 w-3.5" />
|
||
{tab.label}
|
||
{tab.badge && (
|
||
<span className={`text-[9px] px-1 py-0.5 rounded ${active ? 'bg-blue-500/20 text-blue-300' : 'bg-slate-800 text-slate-600'}`}>
|
||
{tab.badge}
|
||
</span>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Tab 内容 */}
|
||
{activeTab === 'basic' ? (
|
||
<DeploymentEditBasic
|
||
clusterCode={clusterCode}
|
||
namespace={namespace}
|
||
deploymentName={deploymentName}
|
||
manifest={manifest}
|
||
onSaved={onSaved}
|
||
onCancel={onCancel}
|
||
/>
|
||
) : activeTab === 'containers' ? (
|
||
/* M7: 完整容器编辑器 — 工作容器 + 初始化容器 */
|
||
<DeploymentEditContainers
|
||
clusterCode={clusterCode}
|
||
namespace={namespace}
|
||
deploymentName={deploymentName}
|
||
manifest={manifest}
|
||
onSaved={onSaved}
|
||
onCancel={onCancel}
|
||
/>
|
||
) : activeTab === 'storage' ? (
|
||
/* M8: 存储挂载 — volumes + volumeMounts 汇总 */
|
||
<DeploymentEditStorage
|
||
clusterCode={clusterCode}
|
||
namespace={namespace}
|
||
deploymentName={deploymentName}
|
||
manifest={manifest}
|
||
onSaved={onSaved}
|
||
onCancel={onCancel}
|
||
/>
|
||
) : activeTab === 'advanced' ? (
|
||
/* M8: 高级设置 — 更新策略/调度/DNS/安全上下文/终止宽限期 */
|
||
<DeploymentEditAdvanced
|
||
clusterCode={clusterCode}
|
||
namespace={namespace}
|
||
deploymentName={deploymentName}
|
||
manifest={manifest}
|
||
onSaved={onSaved}
|
||
onCancel={onCancel}
|
||
/>
|
||
) : activeTab === 'service' ? (
|
||
/* M9: 服务/应用路由 — 关联 Service + Ingress,可创建 */
|
||
<DeploymentEditService
|
||
clusterCode={clusterCode}
|
||
namespace={namespace}
|
||
deploymentName={deploymentName}
|
||
manifest={manifest}
|
||
onSaved={onSaved}
|
||
onCancel={onCancel}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ============ 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 (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||
<div className="bg-slate-900 border border-slate-700 rounded-xl p-6 w-[460px] max-w-[90vw] shadow-2xl" onClick={e => e.stopPropagation()}>
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="text-base font-bold text-slate-100">{title}</h2>
|
||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400" onClick={onClose}><X className="h-4 w-4" /></Button>
|
||
</div>
|
||
{children}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ============ Pod 日志弹窗(带容器切换 + 行数 + 实时流) ============
|
||
function PodLogsModal({ clusterCode, namespace, podName, containers, onClose }: {
|
||
clusterCode: string; namespace: string; podName: string; containers: string[]; onClose: () => void;
|
||
}) {
|
||
const { toast } = useToast();
|
||
const [logs, setLogs] = useState('');
|
||
const [tail, setTail] = useState(200);
|
||
const [container, setContainer] = useState(containers[0] || '');
|
||
const [loading, setLoading] = useState(false);
|
||
const [streaming, setStreaming] = useState(false);
|
||
|
||
const loadLogs = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const res: Record<string, any> = await kubernetesApi.getPodLogs(clusterCode, namespace, podName, tail, container || undefined);
|
||
setLogs(res?.data?.logs || res?.logs || res || '无日志');
|
||
} catch {
|
||
setLogs('获取日志失败');
|
||
} finally { setLoading(false); }
|
||
};
|
||
|
||
useEffect(() => { loadLogs(); }, [container, tail]);
|
||
|
||
const startStream = async () => {
|
||
setStreaming(true);
|
||
const token = useUserStore.getState().token || '';
|
||
const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
|
||
const cParam = container ? `&container=${encodeURIComponent(container)}` : '';
|
||
const url = `${baseUrl}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/logs?tail=${tail}&stream=true${cParam}`;
|
||
try {
|
||
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||
const reader = resp.body?.getReader();
|
||
if (!reader) return;
|
||
const decoder = new TextDecoder();
|
||
let buf = '';
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
buf += decoder.decode(value, { stream: true });
|
||
setLogs(buf);
|
||
}
|
||
} catch {
|
||
toast({ title: '流式日志连接中断', variant: 'destructive' });
|
||
} finally { setStreaming(false); }
|
||
};
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||
<div className="bg-slate-900 border border-slate-700 rounded-xl w-[90vw] max-w-4xl max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||
<div className="flex items-center justify-between p-3 border-b border-slate-700">
|
||
<div className="flex items-center gap-2">
|
||
<FileText className="h-4 w-4 text-blue-400" />
|
||
<span className="text-sm font-semibold text-slate-200">{podName}</span>
|
||
{container && <Badge variant="outline" className="border-slate-700 text-slate-400 text-[10px]">{container}</Badge>}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{containers.length > 0 && (
|
||
<select value={container} onChange={e => setContainer(e.target.value)}
|
||
className="bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-2 py-1">
|
||
{containers.map(c => <option key={c} value={c}>{c}</option>)}
|
||
</select>
|
||
)}
|
||
<span className="text-[11px] text-slate-500">最近</span>
|
||
<Input type="number" value={tail} onChange={e => setTail(Number(e.target.value) || 200)}
|
||
className="w-16 h-7 bg-slate-800 border-slate-700 text-slate-200 text-xs" />
|
||
<span className="text-[11px] text-slate-500">行</span>
|
||
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={loadLogs} disabled={loading}>
|
||
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
|
||
</Button>
|
||
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-emerald-400 text-xs" onClick={startStream} disabled={streaming}>
|
||
{streaming ? '流中...' : '实时流'}
|
||
</Button>
|
||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400" onClick={onClose}><X className="h-4 w-4" /></Button>
|
||
</div>
|
||
</div>
|
||
<div className="flex-1 overflow-auto p-3">
|
||
<pre className="bg-slate-950 rounded p-3 text-xs text-slate-300 font-mono whitespace-pre-wrap max-h-[60vh] overflow-auto">
|
||
{logs || '无日志内容'}
|
||
</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|