Files
aiops/frontend/src/components/kubernetes/DeploymentWorkload.tsx
T
Logan.Li 480e677042 refactor(frontend): P1-P2 续 — API 子模块化、大组件拆分、Query 铺开
- api/server/ 与 api/cloud/ 子目录拆分,保持 @/api 导入不变
- hooks/api 新增 useCredentials、usePromDatasources,增强 useServers
- 拆分 CredentialManager、DeploymentWorkload、PrometheusDatasourceManager
- ServerManager、CredentialManager、PromDs 接入 TanStack Query
2026-06-26 00:22:50 +08:00

460 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// DeploymentWorkload.tsx — Kuboard 风格 Deployment 详情主组件
// - 顶部操作菜单: 编辑 | 预览/编辑YAML | 调整镜像版本 | 伸缩 | 日志/终端 | 重启 | 暂停/恢复更新 | CI/CD发布 | 删除 | 元数据 | 运行时 | 服务 | 应用路由
// - 三模式: view(只读增强看板)/ edit(可视化编辑器)/ yaml(YAML编辑器)
// 作者: Logan.Li
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 { Badge } from '@/components/ui/badge';
import {
ArrowLeft,
Image as ImageIcon,
RotateCw,
Trash2,
RefreshCw,
Loader2,
XCircle,
CheckCircle,
AlertTriangle,
Pause,
Play,
Rocket,
} from 'lucide-react';
import { kubernetesApi } from '@/api';
import { useToast } from '@/hooks/use-toast';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { useUserStore } from '@/store';
import { YamlViewer } from './YamlViewer';
import { DeploymentView } from './DeploymentView';
import { PodExecTerminal } from './PodExecTerminal';
import { PodFileBrowser } from './PodFileBrowser';
import { EditModeContainer } from './deployment-workload/EditModeContainer';
import { CICDWebhookContent } from './deployment-workload/CICDWebhookContent';
import { ModalShell } from './deployment-workload/ModalShell';
import { PodLogsModal } from './deployment-workload/PodLogsModal';
import { ActionModals } from './deployment-workload/ActionModals';
import type { ActionModal, DeploymentWorkloadProps } from './deployment-workload/types';
export type { DeploymentWorkloadProps, WorkloadMode } from './deployment-workload/types';
export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mode, onBack, onModeChange }: DeploymentWorkloadProps) {
const navigate = useNavigate();
const { toast } = useToast();
const confirm = useConfirm();
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 () => {
const ok = await confirm({
title: '滚动重启',
description: `确认滚动重启 Deployment "${deploymentName}"?`,
confirmText: '重启',
});
if (!ok) 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 ? '恢复' : '暂停';
const ok = await confirm({
title: `${action}滚动更新`,
description: `确认${action} Deployment "${deploymentName}" 的滚动更新?`,
confirmText: action,
});
if (!ok) 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}
/>
)}
<ActionModals
modal={modal}
deployment={deployment}
namespace={namespace}
deploymentName={deploymentName}
newImage={newImage}
setNewImage={setNewImage}
scaleValue={scaleValue}
setScaleValue={setScaleValue}
actionLoading={actionLoading}
onClose={() => setModal(null)}
onUpdateImage={handleUpdateImage}
onScaleTo={handleScaleTo}
onDelete={handleDelete}
/>
{/* ============ 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>
);
}