后端(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 数据文件
350 lines
21 KiB
TypeScript
350 lines
21 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, 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>
|
||
);
|
||
}
|