refactor(frontend): P1-P2 续 — API 子模块化、大组件拆分、Query 铺开
- api/server/ 与 api/cloud/ 子目录拆分,保持 @/api 导入不变 - hooks/api 新增 useCredentials、usePromDatasources,增强 useServers - 拆分 CredentialManager、DeploymentWorkload、PrometheusDatasourceManager - ServerManager、CredentialManager、PromDs 接入 TanStack Query
This commit is contained in:
1 parent
f45e203e21
commit
480e677042
32 files changed
+2280
-1940
No files matched your search
@@ -1,75 +1,42 @@
|
||||
// DeploymentWorkload.tsx — Kuboard 风格 Deployment 详情主组件
|
||||
// - 顶部操作菜单: 编辑 | 预览/编辑YAML | 调整镜像版本 | 伸缩 | 日志/终端 | 重启 | 暂停/恢复更新 | CI/CD发布 | 删除 | 元数据 | 运行时 | 服务 | 应用路由
|
||||
// - 三模式: view(只读增强看板)/ edit(可视化编辑器)/ yaml(YAML编辑器)
|
||||
// 作者: Logan <admin@attacker.club>
|
||||
// 作者: 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 { 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,
|
||||
XCircle,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
Pause,
|
||||
Play,
|
||||
Rocket,
|
||||
Container,
|
||||
Settings,
|
||||
Database,
|
||||
} 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 { 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';
|
||||
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';
|
||||
|
||||
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 type { DeploymentWorkloadProps, WorkloadMode } from './deployment-workload/types';
|
||||
|
||||
export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mode, onBack, onModeChange }: DeploymentWorkloadProps) {
|
||||
const navigate = useNavigate();
|
||||
@@ -436,82 +403,21 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ============ 弹窗:调整镜像版本 ============ */}
|
||||
{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>
|
||||
)}
|
||||
<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' && (
|
||||
@@ -551,250 +457,4 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// ActionModals.tsx — 调整镜像 / 伸缩 / 删除 弹窗
|
||||
// 作者: Logan.Li
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { AlertTriangle, Loader2, Minus, Plus, Trash2 } from 'lucide-react';
|
||||
import { ModalShell } from './ModalShell';
|
||||
import type { ActionModal } from './types';
|
||||
|
||||
export interface ActionModalsProps {
|
||||
modal: ActionModal;
|
||||
deployment: any;
|
||||
namespace: string;
|
||||
deploymentName: string;
|
||||
newImage: string;
|
||||
setNewImage: (value: string) => void;
|
||||
scaleValue: number;
|
||||
setScaleValue: (value: number | ((prev: number) => number)) => void;
|
||||
actionLoading: string;
|
||||
onClose: () => void;
|
||||
onUpdateImage: () => void;
|
||||
onScaleTo: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function ActionModals({
|
||||
modal,
|
||||
deployment,
|
||||
namespace,
|
||||
deploymentName,
|
||||
newImage,
|
||||
setNewImage,
|
||||
scaleValue,
|
||||
setScaleValue,
|
||||
actionLoading,
|
||||
onClose,
|
||||
onUpdateImage,
|
||||
onScaleTo,
|
||||
onDelete,
|
||||
}: ActionModalsProps) {
|
||||
return (
|
||||
<>
|
||||
{/* ============ 弹窗:调整镜像版本 ============ */}
|
||||
{modal?.kind === 'image' && (
|
||||
<ModalShell title="调整镜像版本" onClose={onClose}>
|
||||
<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={onClose}>取消</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" disabled={actionLoading === 'image' || !newImage.trim()} onClick={onUpdateImage}>
|
||||
{actionLoading === 'image' ? <Loader2 className="h-4 w-4 animate-spin" /> : '更新镜像'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
)}
|
||||
|
||||
{/* ============ 弹窗:伸缩 ============ */}
|
||||
{modal?.kind === 'scale' && (
|
||||
<ModalShell title="伸缩 (Scale)" onClose={onClose}>
|
||||
<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={onClose}>取消</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" disabled={actionLoading === 'scale'} onClick={onScaleTo}>
|
||||
{actionLoading === 'scale' ? <Loader2 className="h-4 w-4 animate-spin" /> : '确认'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
)}
|
||||
|
||||
{/* ============ 弹窗:删除 ============ */}
|
||||
{modal?.kind === 'delete' && (
|
||||
<ModalShell title="删除 Deployment" onClose={onClose}>
|
||||
<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={onClose}>取消</Button>
|
||||
<Button variant="destructive" disabled={actionLoading === 'delete'} onClick={onDelete}>
|
||||
{actionLoading === 'delete' ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Trash2 className="h-4 w-4 mr-1" />}
|
||||
确认删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// CICDWebhookContent.tsx — CI/CD Webhook 弹窗内容
|
||||
// 作者: Logan.Li
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
export interface CICDWebhookContentProps {
|
||||
clusterCode: string;
|
||||
namespace: string;
|
||||
deploymentName: string;
|
||||
}
|
||||
|
||||
export function CICDWebhookContent({ clusterCode, namespace, deploymentName }: CICDWebhookContentProps) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// EditModeContainer.tsx — 编辑模式 Tab 容器(M6: 基本信息Tab + 容器编辑预留)
|
||||
// 作者: Logan.Li
|
||||
import { useState } from 'react';
|
||||
import { Container, Settings, Database, Network } from 'lucide-react';
|
||||
import { DeploymentEditBasic } from '../DeploymentEditBasic';
|
||||
import { DeploymentEditContainers } from '../DeploymentEditContainers';
|
||||
import { DeploymentEditStorage } from '../DeploymentEditStorage';
|
||||
import { DeploymentEditAdvanced } from '../DeploymentEditAdvanced';
|
||||
import { DeploymentEditService } from '../DeploymentEditService';
|
||||
|
||||
export interface EditModeContainerProps {
|
||||
clusterCode: string;
|
||||
namespace: string;
|
||||
deploymentName: string;
|
||||
manifest: any | null;
|
||||
onSaved: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function EditModeContainer({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: EditModeContainerProps) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// ModalShell.tsx — 通用 Modal 外壳
|
||||
// 作者: Logan.Li
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
export interface ModalShellProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ModalShell({ title, children, onClose }: ModalShellProps) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// PodLogsModal.tsx — Pod 日志弹窗(带容器切换 + 行数 + 实时流)
|
||||
// 作者: Logan.Li
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { FileText, RefreshCw, Loader2, X } from 'lucide-react';
|
||||
import { kubernetesApi } from '@/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
export interface PodLogsModalProps {
|
||||
clusterCode: string;
|
||||
namespace: string;
|
||||
podName: string;
|
||||
containers: string[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PodLogsModal({ clusterCode, namespace, podName, containers, onClose }: PodLogsModalProps) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// deployment-workload/types.ts — Deployment 详情页类型定义
|
||||
// 作者: Logan.Li
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export type ActionModal =
|
||||
| { kind: 'yaml' }
|
||||
| { kind: 'image' }
|
||||
| { kind: 'scale' }
|
||||
| { kind: 'podLogs'; pod: string }
|
||||
| { kind: 'podYaml'; pod: string }
|
||||
| { kind: 'delete' }
|
||||
| { kind: 'cicd' }
|
||||
| null;
|
||||
Reference in new issue
Block a user