Files
FlowStack/frontend/src/pages/GitOpsDeploy.tsx
T
Logan.Li 04d1342b27 feat: FlowStack 发布系统首个可运行版本 (gin + React)
- Go gin 后端:流水线/节点/连线/运行/日志/GitOps/插件 REST API,
  SQLite(GORM) 自动建表 + seed,模拟执行引擎 + SSE 实时日志推送,
  前端产物 go:embed 嵌入单二进制,一个端口同时服务 API 与页面
- React 19 前端:画布式流水线编辑器(React Flow + Tekton 任务库)、
  GitOps 部署、批量发布、发布记录等 14 个模块页面,
  tRPC 全部替换为 REST(TanStack Query)
- 首次启动生成随机密码管理员账号(日志 + data/initial-admin-password.txt)
- run.sh 一键构建启动(默认端口 9527,支持 start/stop/restart/build/dev)
2026-10-27 02:44:10 +08:00

359 lines
22 KiB
TypeScript

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 } 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 mockApps: Application[] = [
{
id: 'app-1', name: 'aiops-backend', repo: 'https://gitee.com/attacker/aiops-gitops.git',
branch: 'main', path: 'apps/aiops-backend/overlays/production',
cluster: 'production-k8s', namespace: 'aiops', status: 'synced', syncStatus: 'synced',
health: 'healthy', lastSync: '2分钟前', resources: 12, manifestType: 'kustomize', autoSync: true,
},
{
id: 'app-2', name: 'aiops-web', repo: 'https://gitee.com/attacker/aiops-gitops.git',
branch: 'main', path: 'apps/aiops-web/overlays/production',
cluster: 'production-k8s', namespace: 'aiops', status: 'syncing', syncStatus: 'out-of-sync',
health: 'progressing', lastSync: '正在同步', resources: 8, manifestType: 'helm', autoSync: true,
},
{
id: 'app-3', name: 'gateway-api', repo: 'https://gitee.com/attacker/aiops-gitops.git',
branch: 'release/v2.1', path: 'apps/gateway/overlays/staging',
cluster: 'staging-k8s', namespace: 'gateway', status: 'out-of-sync', syncStatus: 'out-of-sync',
health: 'degraded', lastSync: '1小时前', resources: 6, manifestType: 'kustomize', autoSync: false,
},
{
id: 'app-4', name: 'data-processor', repo: 'https://gitee.com/attacker/aiops-gitops.git',
branch: 'main', path: 'apps/data/overlays/production',
cluster: 'production-k8s', namespace: 'data', status: 'synced', syncStatus: 'synced',
health: 'healthy', lastSync: '30分钟前', resources: 5, manifestType: 'plain', autoSync: true,
},
];
const mockResourceTree: ResourceNode[] = [
{
id: 'r1', kind: 'Deployment', name: 'aiops-backend', namespace: 'aiops', status: 'healthy',
children: [
{ id: 'r1-1', kind: 'ReplicaSet', name: 'aiops-backend-7d9f4', namespace: 'aiops', status: 'healthy',
children: [
{ id: 'r1-1-1', kind: 'Pod', name: 'aiops-backend-7d9f4-abc1', namespace: 'aiops', status: 'healthy' },
{ id: 'r1-1-2', kind: 'Pod', name: 'aiops-backend-7d9f4-abc2', namespace: 'aiops', status: 'healthy' },
{ id: 'r1-1-3', kind: 'Pod', name: 'aiops-backend-7d9f4-abc3', namespace: 'aiops', status: 'healthy' },
]
},
]
},
{
id: 'r2', kind: 'Service', name: 'aiops-backend-svc', namespace: 'aiops', status: 'healthy',
},
{
id: 'r3', kind: 'Ingress', name: 'aiops-backend-ingress', namespace: 'aiops', status: 'healthy',
},
{
id: 'r4', kind: 'ConfigMap', name: 'aiops-backend-config', namespace: 'aiops', status: 'healthy',
},
];
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 { data: dbApps = [] } = useQuery({ queryKey: ['gitops', 'apps'], queryFn: () => api<any[]>('/api/gitops/apps') });
const { data: argoApps = [] } = useQuery({ queryKey: ['gitops', 'argo'], queryFn: () => api<any[]>('/api/gitops/argo/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),
onError: (err: Error) => toast.error(err.message),
});
const apps: Application[] = dbApps.length > 0
? dbApps.map((db: any) => {
const argo = (argoApps as any[]).find((a: any) => a.metadata?.name === db.name);
return {
id: String(db.id),
name: db.name,
repo: db.repo,
branch: db.branch,
path: db.path,
cluster: db.cluster,
namespace: db.namespace,
status: argo?.status?.sync?.status === 'Synced' ? 'synced' : argo?.status?.sync?.status === 'OutOfSync' ? 'out-of-sync' : 'unknown',
syncStatus: argo?.status?.sync?.status === 'Synced' ? 'synced' : 'out-of-sync',
health: (argo?.status?.health?.status?.toLowerCase() || 'unknown') as any,
lastSync: '刚刚',
resources: 0,
manifestType: db.manifestType,
autoSync: db.syncPolicy === 'automated',
};
})
: mockApps;
const selectedApp = apps.find(a => a.id === selectedAppId);
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={() => 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)' }}>资源树</span>
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{mockResourceTree.length} 个顶级资源</span>
</div>
<div className="py-2">
{mockResourceTree.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 集群差异</h3>
<div className="space-y-2">
<div className="flex items-center gap-3 p-2 rounded-md" style={{ background: '#dafbe1' }}>
<CheckCircle2 className="w-4 h-4" style={{ color: '#1a7f37' }} />
<span className="text-[12px]" style={{ color: '#1a7f37' }}>Deployment/aiops-backend: Git 和集群一致</span>
</div>
<div className="flex items-center gap-3 p-2 rounded-md" style={{ background: '#fff8c5' }}>
<AlertTriangle className="w-4 h-4" style={{ color: '#9a6700' }} />
<span className="text-[12px]" style={{ color: '#9a6700' }}>Service/aiops-backend-svc: 集群中端口被手动修改 (80 {'->'} 8080)</span>
</div>
<div className="flex items-center gap-3 p-2 rounded-md" style={{ background: '#ffebe9' }}>
<XCircle className="w-4 h-4" style={{ color: '#cf222e' }} />
<span className="text-[12px]" style={{ color: '#cf222e' }}>ConfigMap/aiops-backend-config: Git 中新增,集群中不存在</span>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}