Files
FlowStack/frontend/src/pages/GitOpsDeploy.tsx
T
Logan.Li 26341c3e8a feat: 去 mock 化 —— 全模块接入真实数据 (参考 jianmu/opsx-jenkins/aiops)
后端(35+ 新路由):
- AES-256-GCM 字段加密(enc:v1:),密钥自动生成 data/encryption.key
- 新模型:Host/K8sCluster/Credential/BuildNode/DeployTool/WebhookEndpoint/
  JenkinsServer/SystemSetting;Pipeline 扩展触发器/变量/缓存列,
  PipelineRun 扩展 buildNumber/externalUrl
- 资源 CRUD + 真实测试端点:主机/集群/构建节点 TCP 拨号、
  工具真实执行版本检测(保守安装策略)、Webhook HMAC-SHA256 签名发送、
  入站流水线触发 /api/hooks/pipeline/:id
- Jenkins 完整集成(移植 opsx-jenkins 生产客户端):crumb/BasicAuth/
  buildWithParameters/Location 队列/progressiveText 增量控制台,
  服务器 CRUD+测试、Job 树拉取(folder 压平)、导入为流水线、
  jenkins 类型流水线真实执行回传日志
- 流水线列表附带 lastRun、运行重试、summary 统计、系统设置单行表
- 运行结束触发出站 Webhook 通知;GitOps 同步状态真实推进

前端(14 页全部去 mock):
- 流水线列表/发布记录/批量发布/环境/凭证/构建节点/工具/Webhook/
  系统设置/Jenkins 导入/GitOps(资源树+差异) 全部接入真实 API
- 编辑器 4 Tab 持久化(基本信息/触发设置含真实 Webhook URL/变量缓存/发布历史)
- TopBar 真实流水线切换 + 仅保存/保存并运行
- Sidebar 徽标真实统计;删除未使用组件与 mock 数据文件
2026-10-27 03:15:20 +08:00

350 lines
21 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.
import { useState } from 'react';
import {
CloudSync, GitBranch, RefreshCw, CheckCircle2, XCircle,
Clock, ChevronRight, TreePine, Diff, Layers,
Server, AlertTriangle, FolderGit, Package
} from 'lucide-react';
import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
interface Application {
id: string;
name: string;
repo: string;
branch: string;
path: string;
cluster: string;
namespace: string;
status: 'synced' | 'out-of-sync' | 'syncing' | 'error' | 'unknown';
syncStatus: 'synced' | 'out-of-sync';
health: 'healthy' | 'degraded' | 'progressing' | 'suspended';
lastSync: string;
resources: number;
manifestType: 'helm' | 'kustomize' | 'plain';
autoSync: boolean;
}
interface ResourceNode {
id: string;
kind: string;
name: string;
namespace: string;
status: 'healthy' | 'degraded' | 'progressing' | 'suspended';
children?: ResourceNode[];
}
const statusConfig: Record<string, { color: string; bg: string; label: string; icon: typeof CheckCircle2 }> = {
synced: { color: '#1a7f37', bg: '#dafbe1', label: '已同步', icon: CheckCircle2 },
'out-of-sync': { color: '#9a6700', bg: '#fff8c5', label: '待同步', icon: Clock },
syncing: { color: '#0969da', bg: '#ddf4ff', label: '同步中', icon: RefreshCw },
error: { color: '#cf222e', bg: '#ffebe9', label: '错误', icon: XCircle },
unknown: { color: '#6e7781', bg: '#f6f8fa', label: '未知', icon: AlertTriangle },
};
const healthConfig: Record<string, { color: string; label: string; bg: string }> = {
healthy: { color: '#1a7f37', label: '健康', bg: '#dafbe1' },
degraded: { color: '#cf222e', label: '降级', bg: '#ffebe9' },
progressing: { color: '#9a6700', label: '进行中', bg: '#fff8c5' },
suspended: { color: '#6e7781', label: '暂停', bg: '#f6f8fa' },
};
const manifestConfig: Record<string, { label: string; icon: typeof Package }> = {
helm: { label: 'Helm', icon: Package },
kustomize: { label: 'Kustomize', icon: Layers },
plain: { label: 'Plain YAML', icon: FolderGit },
};
export default function GitOpsDeploy() {
const [selectedAppId, setSelectedAppId] = useState<string | null>(null);
const [activeView, setActiveView] = useState<'list' | 'tree' | 'diff'>('list');
const queryClient = useQueryClient();
const { data: dbApps = [] } = useQuery({ queryKey: ['gitops', 'apps'], queryFn: () => api<any[]>('/api/gitops/apps') });
const syncMutation = useMutation({
mutationFn: (appName: string) => api<any>('/api/gitops/argo/sync', { method: 'POST', body: JSON.stringify({ appName }) }),
onSuccess: (data) => {
toast.success(data.message);
queryClient.invalidateQueries({ queryKey: ['gitops'] });
},
onError: (err: Error) => toast.error(err.message),
});
// dbApps 直接携带 syncStatus/healthStatus(sync 动作会真实推进状态)
const apps: Application[] = dbApps.map((db: any) => {
const sync = (db.syncStatus || '').toLowerCase();
return {
id: String(db.id),
name: db.name,
repo: db.repo,
branch: db.branch,
path: db.path,
cluster: db.cluster,
namespace: db.namespace,
status: sync === 'synced' ? 'synced' : sync === 'syncing' ? 'syncing' : sync === 'outofsync' ? 'out-of-sync' : 'unknown',
syncStatus: sync === 'synced' ? 'synced' : 'out-of-sync',
health: (db.healthStatus || 'unknown').toLowerCase(),
lastSync: db.lastSyncAt ? new Date(db.lastSyncAt).toLocaleString('zh-CN', { hour12: false }) : '-',
resources: 0,
manifestType: db.manifestType,
autoSync: db.syncPolicy === 'automated',
};
});
const selectedApp = apps.find(a => a.id === selectedAppId) || apps[0];
const selectedName = selectedApp?.name;
// 资源树 / 差异:真实调用后端生成端点
const { data: treeData, isLoading: treeLoading } = useQuery({
queryKey: ['gitops', 'tree', selectedName],
queryFn: () => api<any>(`/api/gitops/argo/${selectedName}/tree`),
enabled: activeView === 'tree' && !!selectedName,
});
const { data: diffData, isLoading: diffLoading } = useQuery({
queryKey: ['gitops', 'diff', selectedName],
queryFn: () => api<any>(`/api/gitops/argo/${selectedName}/diff`),
enabled: activeView === 'diff' && !!selectedName,
});
// nodes+edges 扁平结构 → 树
const resourceTree: ResourceNode[] = (() => {
if (!treeData?.nodes) return [];
const byUid = new Map<string, ResourceNode>();
for (const n of treeData.nodes) {
byUid.set(n.uid, { id: n.uid, kind: n.kind, name: n.name, namespace: n.namespace, status: n.status });
}
const childSet = new Set<string>();
for (const e of treeData.edges || []) {
const parent = byUid.get(e.source);
const child = byUid.get(e.target);
if (parent && child) {
parent.children = parent.children || [];
parent.children.push(child);
childSet.add(e.target);
}
}
return treeData.nodes.filter((n: any) => !childSet.has(n.uid)).map((n: any) => byUid.get(n.uid)!);
})();
const renderResourceNode = (node: ResourceNode, depth: number = 0) => {
const health = healthConfig[node.status];
const hasChildren = node.children && node.children.length > 0;
return (
<div key={node.id} className="select-none">
<div
className="flex items-center gap-2 py-1.5 px-3 hover:bg-black/[0.02] rounded-md cursor-pointer"
style={{ paddingLeft: `${12 + depth * 20}px` }}
>
{hasChildren ? <ChevronRight className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /> : <div className="w-3.5" />}
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: health.color }} />
<span className="text-[11px] font-medium px-1.5 py-[1px] rounded" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>{node.kind}</span>
<span className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>{node.name}</span>
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{node.namespace}</span>
</div>
{hasChildren && node.children!.map(child => renderResourceNode(child, depth + 1))}
</div>
);
};
return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
{/* Header */}
<div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-center gap-3">
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>GitOps 部署</h1>
<span className="text-[11px] px-2 py-[2px] rounded-full font-medium" style={{ background: '#ddf4ff', color: 'var(--accent-blue)' }}>Argo CD</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center border rounded-md overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
{[
{ id: 'list', label: '应用列表', icon: Layers },
{ id: 'tree', label: '资源树', icon: TreePine },
{ id: 'diff', label: '差异对比', icon: Diff },
].map(v => (
<button key={v.id} onClick={() => setActiveView(v.id as any)}
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium transition-colors"
style={{ background: activeView === v.id ? 'var(--panel-bg-secondary)' : 'transparent', color: activeView === v.id ? 'var(--accent-blue)' : 'var(--text-secondary)' }}>
<v.icon className="w-3.5 h-3.5" />{v.label}
</button>
))}
</div>
<button onClick={() => { queryClient.invalidateQueries({ queryKey: ['gitops'] }); toast.success('已刷新'); }} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<RefreshCw className="w-3.5 h-3.5" />刷新
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden flex">
{/* App list sidebar */}
<div className="w-[280px] border-r flex flex-col flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>Applications ({apps.length})</div>
<div className="flex-1 overflow-y-auto custom-scrollbar">
{apps.map(app => {
const st = statusConfig[app.status];
const hl = healthConfig[app.health];
const mf = manifestConfig[app.manifestType];
const isSelected = selectedAppId === app.id;
return (
<div key={app.id} onClick={() => setSelectedAppId(app.id)}
className="mx-2 mb-1 p-2.5 rounded-md cursor-pointer border transition-all"
style={{
background: isSelected ? 'var(--accent-blue-light)' : 'transparent',
borderColor: isSelected ? 'var(--accent-blue)' : 'transparent',
}}>
<div className="flex items-center justify-between mb-1">
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>{app.name}</span>
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium flex items-center gap-1" style={{ background: st.bg, color: st.color }}>
<st.icon className="w-3 h-3" />{st.label}
</span>
</div>
<div className="flex items-center gap-1.5 mb-1">
<GitBranch className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
<span className="text-[11px] font-mono truncate" style={{ color: 'var(--text-secondary)' }}>{app.repo.replace('https://', '').replace('.git', '')}</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[10px] flex items-center gap-1" style={{ color: 'var(--text-tertiary)' }}><mf.icon className="w-3 h-3" />{mf.label}</span>
<span className="text-[10px]" style={{ color: hl.color }}>{hl.label}</span>
</div>
<span className="text-[10px]" style={{ color: 'var(--text-tertiary)' }}>{app.lastSync}</span>
</div>
</div>
);
})}
</div>
</div>
{/* Detail view */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
{!selectedApp ? (
<div className="h-full flex flex-col items-center justify-center text-center" style={{ color: 'var(--text-tertiary)' }}>
<CloudSync className="w-12 h-12 mb-3 opacity-30" />
<p className="text-[14px]">选择一个 Application 查看详情</p>
<p className="text-[12px] mt-1">Git 仓库作为部署声明的单一事实来源</p>
</div>
) : activeView === 'list' ? (
<div className="space-y-4">
{/* App header */}
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<h2 className="text-[16px] font-semibold" style={{ color: 'var(--text-primary)' }}>{selectedApp.name}</h2>
<span className="text-[10px] px-2 py-[2px] rounded-full font-medium" style={{ background: statusConfig[selectedApp.status].bg, color: statusConfig[selectedApp.status].color }}>
{statusConfig[selectedApp.status].label}
</span>
<span className="text-[10px] px-2 py-[2px] rounded-full font-medium" style={{ background: healthConfig[selectedApp.health].bg || '#f6f8fa', color: healthConfig[selectedApp.health].color }}>
{healthConfig[selectedApp.health].label}
</span>
</div>
<div className="flex items-center gap-2">
<button onClick={() => syncMutation.mutate(selectedApp.name)}
disabled={syncMutation.isPending}
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md text-white disabled:opacity-50" style={{ background: 'var(--accent-blue)' }}>
<RefreshCw className={`w-3.5 h-3.5 ${syncMutation.isPending ? 'animate-spin' : ''}`} />立即同步
</button>
<button onClick={() => toast.info('回滚', { description: '选择要回滚的版本' })}
className="px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
回滚
</button>
</div>
</div>
<div className="grid grid-cols-4 gap-4 text-[12px]">
<div><div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>Git 仓库</div><div className="font-mono" style={{ color: 'var(--text-primary)' }}>{selectedApp.repo}</div></div>
<div><div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>分支 / 路径</div><div style={{ color: 'var(--text-primary)' }}>{selectedApp.branch} / {selectedApp.path}</div></div>
<div><div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>目标集群</div><div className="flex items-center gap-1" style={{ color: 'var(--text-primary)' }}><Server className="w-3 h-3" />{selectedApp.cluster}</div></div>
<div><div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>命名空间</div><div style={{ color: 'var(--text-primary)' }}>{selectedApp.namespace}</div></div>
</div>
</div>
{/* Sync config */}
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<h3 className="text-[13px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>同步配置</h3>
<div className="grid grid-cols-3 gap-4">
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 text-[12px] cursor-pointer">
<input type="checkbox" checked={selectedApp.autoSync} readOnly className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} />
<span style={{ color: 'var(--text-primary)' }}>自动同步</span>
</label>
</div>
<div><label className="block text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>同步策略</label>
<select className="w-full px-2 py-[5px] text-[12px] rounded-md border" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
<option>自动(Git 变更触发)</option><option>手动(仅点击同步)</option><option>定时同步</option>
</select></div>
<div><label className="block text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>Prune 资源</label>
<select className="w-full px-2 py-[5px] text-[12px] rounded-md border" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
<option>自动清理已删除资源</option><option>保留所有资源</option>
</select></div>
</div>
</div>
{/* Environment promotion */}
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<h3 className="text-[13px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>环境晋升</h3>
<div className="flex items-center gap-3">
<div className="flex-1 rounded-md border p-3 text-center" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}>
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>开发环境</div>
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>dev</div>
<div className="text-[10px] mt-1" style={{ color: '#1a7f37' }}>已同步</div>
</div>
<ChevronRight className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />
<div className="flex-1 rounded-md border p-3 text-center" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}>
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>测试环境</div>
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>staging</div>
<div className="text-[10px] mt-1" style={{ color: '#9a6700' }}>待同步</div>
</div>
<ChevronRight className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />
<div className="flex-1 rounded-md border p-3 text-center" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}>
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>生产环境</div>
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>production</div>
<div className="text-[10px] mt-1" style={{ color: '#1a7f37' }}>已同步</div>
</div>
<button onClick={() => toast.success('晋升', { description: '将 staging 版本晋升到 production' })}
className="px-3 py-[7px] text-[12px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>
晋升
</button>
</div>
</div>
</div>
) : activeView === 'tree' ? (
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="px-4 py-2 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>资源树{selectedName ? ` · ${selectedName}` : ''}</span>
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{resourceTree.length} 个顶级资源</span>
</div>
<div className="py-2">
{treeLoading && <div className="py-6 text-center text-[12px]" style={{ color: 'var(--text-tertiary)' }}>加载中…</div>}
{!treeLoading && resourceTree.map(node => renderResourceNode(node))}
</div>
</div>
) : (
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<h3 className="text-[13px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>Git vs 集群差异{selectedName ? ` · ${selectedName}` : ''}</h3>
{diffLoading && <div className="py-6 text-center text-[12px]" style={{ color: 'var(--text-tertiary)' }}>加载中…</div>}
<div className="space-y-2">
{(diffData?.diffs || []).map((d: any, i: number) => (
<div key={i} className="rounded-md border overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
<div className="flex items-center gap-3 px-3 py-2" style={{ background: d.status === 'modified' ? '#fff8c5' : '#f6f8fa' }}>
<AlertTriangle className="w-4 h-4" style={{ color: d.status === 'modified' ? '#9a6700' : '#6e7781' }} />
<span className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>{d.kind}/{d.name}</span>
<span className="text-[10px] px-1.5 py-[1px] rounded" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>{d.namespace}</span>
<span className="text-[11px] ml-auto" style={{ color: d.status === 'modified' ? '#9a6700' : 'var(--text-tertiary)' }}>{d.status}</span>
</div>
<pre className="px-4 py-2 text-[11px] font-mono whitespace-pre-wrap" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>{d.diff}</pre>
</div>
))}
{!diffLoading && (diffData?.diffs || []).length === 0 && (
<div className="flex items-center gap-3 p-3 rounded-md" style={{ background: '#dafbe1' }}>
<CheckCircle2 className="w-4 h-4" style={{ color: '#1a7f37' }} />
<span className="text-[12px]" style={{ color: '#1a7f37' }}>Git 与集群一致,无差异</span>
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
}