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 数据文件
This commit is contained in:
Logan.Li committed 2026-10-27 03:15:20 +08:00
1 parent 04d1342b27
commit 26341c3e8a
37 files changed
+5702 -4683

No files matched your search

+94 -249
View File
@@ -1,270 +1,115 @@
import { useState } from 'react';
import { Bell, Info, TestTube } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useEffect, useState } from 'react';
import { Info, Save, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api';
export default function BasicInfoPanel() {
const [pipelineName, setPipelineName] = useState('gateway-pre-serverless');
const [description, setDescription] = useState('');
interface Props {
pipelineId: number;
}
// Feishu notification
const [feishuEnabled, setFeishuEnabled] = useState(false);
const [feishuWebhook, setFeishuWebhook] = useState('');
const [feishuTiming, setFeishuTiming] = useState('all');
export default function BasicInfoPanel({ pipelineId }: Props) {
const queryClient = useQueryClient();
const { data: detail } = useQuery({
queryKey: ['pipeline', pipelineId],
queryFn: () => apiClient.getPipeline(pipelineId),
});
const p = detail?.pipeline;
// DingTalk notification
const [dingtalkEnabled, setDingtalkEnabled] = useState(false);
const [dingtalkWebhook, setDingtalkWebhook] = useState('');
const [dingtalkTiming, setDingtalkTiming] = useState('all');
const [form, setForm] = useState({ name: '', description: '', sourceRepo: '', sourceBranch: '', deployType: 'host', env: 'staging', notifyWebhookURL: '' });
const handleTestConnection = (type: 'feishu' | 'dingtalk') => {
toast.success(`${type === 'feishu' ? '飞书' : '钉钉'} Webhook 连接测试成功`, {
description: '通知服务可正常访问',
});
};
useEffect(() => {
if (p) {
setForm({
name: p.name || '', description: p.description || '',
sourceRepo: p.sourceRepo || '', sourceBranch: p.sourceBranch || 'main',
deployType: p.deployType || 'host', env: p.env || 'staging',
notifyWebhookURL: p.notifyWebhookURL || '',
});
}
}, [p?.id, p?.updatedAt]);
const timingOptions = [
{ value: 'all', label: '全部事件' },
{ value: 'success', label: '仅成功' },
{ value: 'failure', label: '仅失败' },
];
const saveMutation = useMutation({
mutationFn: (body: any) => apiClient.updatePipeline(pipelineId, body),
onSuccess: () => {
toast.success('基本信息已保存');
queryClient.invalidateQueries({ queryKey: ['pipeline', pipelineId] });
queryClient.invalidateQueries({ queryKey: ['pipelines'] });
},
onError: (err: Error) => toast.error('保存失败', { description: err.message }),
});
const set = (k: string, v: string) => setForm((f) => ({ ...f, [k]: v }));
const inputStyle = { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' };
return (
<div className="tab-content-fade space-y-6 w-full px-6 py-6">
{/* Basic Information */}
<div
className="rounded-lg border p-6 shadow-sm"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--border-color)',
}}
>
<div className="flex items-center gap-2 mb-5">
<Info className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
<h3
className="text-base font-semibold"
style={{ color: 'var(--text-primary)' }}
>
基本信息
</h3>
<div className="tab-content-fade w-full px-6 py-6 max-w-3xl">
<div className="rounded-lg border p-6 shadow-sm" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2">
<Info className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
<h3 className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>基本信息</h3>
{p?.type === 'jenkins' && (
<span className="text-[10px] px-1.5 py-[1px] rounded" style={{ background: '#fbefff', color: '#8250df' }}>Jenkins 导入</span>
)}
</div>
<div className="flex items-center gap-3">
{p && <span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>更新于 {new Date(p.updatedAt).toLocaleString('zh-CN', { hour12: false })}</span>}
<button
onClick={() => saveMutation.mutate(form)}
disabled={saveMutation.isPending || !form.name}
className="flex items-center gap-1 px-3 py-1 text-[12px] rounded-md text-white disabled:opacity-50"
style={{ background: 'var(--accent-blue)' }}
>
{saveMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}保存
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
<div>
<Label className="text-sm font-medium mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
流水线名称
</Label>
<Input
value={pipelineName}
onChange={(e) => setPipelineName(e.target.value)}
placeholder="输入流水线名称"
className="h-9"
/>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>流水线名称 *</label>
<input value={form.name} onChange={(e) => set('name', e.target.value)} className="w-full px-3 py-2 text-[13px] rounded-md border outline-none" style={inputStyle} />
</div>
<div>
<Label className="text-sm font-medium mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
描述
</Label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="输入流水线描述(可选)"
rows={1}
className="w-full px-3 py-2 text-sm rounded-md border outline-none focus:ring-1 focus:ring-[var(--accent-blue)] focus:border-transparent transition-all resize-none h-9"
style={{
background: 'var(--canvas-bg)',
borderColor: 'var(--border-color)',
color: 'var(--text-primary)',
}}
/>
<div className="space-y-1.5">
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>默认分支</label>
<input value={form.sourceBranch} onChange={(e) => set('sourceBranch', e.target.value)} className="w-full px-3 py-2 text-[13px] rounded-md border outline-none" style={inputStyle} />
</div>
<div>
<Label className="text-xs mb-1 block" style={{ color: 'var(--text-secondary)', opacity: 0.7 }}>
创建人
</Label>
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
admin
</span>
<div className="space-y-1.5 col-span-2">
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>描述</label>
<input value={form.description} onChange={(e) => set('description', e.target.value)} className="w-full px-3 py-2 text-[13px] rounded-md border outline-none" style={inputStyle} />
</div>
<div>
<Label className="text-xs mb-1 block" style={{ color: 'var(--text-secondary)', opacity: 0.7 }}>
创建时间
</Label>
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
2024-06-20
</span>
<div className="space-y-1.5 col-span-2">
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>代码仓库</label>
<input value={form.sourceRepo} onChange={(e) => set('sourceRepo', e.target.value)} placeholder="https://gitee.com/org/repo.git" className="w-full px-3 py-2 text-[13px] rounded-md border outline-none font-mono" style={inputStyle} />
</div>
<div>
<Label className="text-xs mb-1 block" style={{ color: 'var(--text-secondary)', opacity: 0.7 }}>
最近执行
</Label>
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
未执行
</span>
<div className="space-y-1.5">
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>部署方式</label>
<select value={form.deployType} onChange={(e) => set('deployType', e.target.value)} className="w-full px-3 py-2 text-[13px] rounded-md border outline-none" style={inputStyle}>
<option value="host">主机部署</option>
<option value="k8s">K8s 部署</option>
<option value="gitops">GitOps</option>
</select>
</div>
<div className="space-y-1.5">
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>默认环境</label>
<select value={form.env} onChange={(e) => set('env', e.target.value)} className="w-full px-3 py-2 text-[13px] rounded-md border outline-none" style={inputStyle}>
<option value="dev">dev</option>
<option value="test">test</option>
<option value="staging">staging</option>
<option value="production">production</option>
</select>
</div>
</div>
</div>
{/* Notification Settings */}
<div
className="rounded-lg border p-6 shadow-sm"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--border-color)',
}}
>
<div className="flex items-center gap-2 mb-5">
<Bell className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
<h3
className="text-base font-semibold"
style={{ color: 'var(--text-primary)' }}
>
通知设置
</h3>
</div>
<div className="space-y-6">
{/* Feishu */}
<div>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
飞书通知
</span>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
</TooltipTrigger>
<TooltipContent>
<p>通过飞书群机器人发送流水线执行通知</p>
</TooltipContent>
</Tooltip>
</div>
<Switch checked={feishuEnabled} onCheckedChange={setFeishuEnabled} />
</div>
{feishuEnabled && (
<div className="tab-content-fade pl-0 space-y-3">
<div>
<Label className="text-xs mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
Webhook 地址
</Label>
<Input
value={feishuWebhook}
onChange={(e) => setFeishuWebhook(e.target.value)}
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
className="h-9"
/>
</div>
<div>
<Label className="text-xs mb-2 block" style={{ color: 'var(--text-secondary)' }}>
通知时机
</Label>
<RadioGroup
value={feishuTiming}
onValueChange={setFeishuTiming}
className="flex items-center gap-4"
>
{timingOptions.map((opt) => (
<div key={opt.value} className="flex items-center space-x-2">
<RadioGroupItem value={opt.value} id={`feishu-${opt.value}`} />
<Label htmlFor={`feishu-${opt.value}`} className="text-sm cursor-pointer">
{opt.label}
</Label>
</div>
))}
</RadioGroup>
</div>
<div className="pt-1">
<Button
variant="outline"
size="sm"
onClick={() => handleTestConnection('feishu')}
className="h-8"
>
<TestTube className="w-3.5 h-3.5 mr-1.5" />
测试连接
</Button>
</div>
</div>
)}
</div>
<Separator style={{ backgroundColor: 'var(--border-color)' }} />
{/* DingTalk */}
<div>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
钉钉通知
</span>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
</TooltipTrigger>
<TooltipContent>
<p>通过钉钉群机器人发送流水线执行通知</p>
</TooltipContent>
</Tooltip>
</div>
<Switch checked={dingtalkEnabled} onCheckedChange={setDingtalkEnabled} />
</div>
{dingtalkEnabled && (
<div className="tab-content-fade pl-0 space-y-3">
<div>
<Label className="text-xs mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
Webhook 地址
</Label>
<Input
value={dingtalkWebhook}
onChange={(e) => setDingtalkWebhook(e.target.value)}
placeholder="https://oapi.dingtalk.com/robot/send?access_token=..."
className="h-9"
/>
</div>
<div>
<Label className="text-xs mb-2 block" style={{ color: 'var(--text-secondary)' }}>
通知时机
</Label>
<RadioGroup
value={dingtalkTiming}
onValueChange={setDingtalkTiming}
className="flex items-center gap-4"
>
{timingOptions.map((opt) => (
<div key={opt.value} className="flex items-center space-x-2">
<RadioGroupItem value={opt.value} id={`dingtalk-${opt.value}`} />
<Label htmlFor={`dingtalk-${opt.value}`} className="text-sm cursor-pointer">
{opt.label}
</Label>
</div>
))}
</RadioGroup>
</div>
<div className="pt-1">
<Button
variant="outline"
size="sm"
onClick={() => handleTestConnection('dingtalk')}
className="h-8"
>
<TestTube className="w-3.5 h-3.5 mr-1.5" />
测试连接
</Button>
</div>
</div>
)}
</div>
<div className="rounded-lg border p-6 shadow-sm mt-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<h3 className="text-[14px] font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>发布通知</h3>
<p className="text-[11px] mb-4" style={{ color: 'var(--text-tertiary)' }}>流水线成功/失败后回调该 Webhook(也在「Webhook 配置」中统一管理)</p>
<div className="space-y-1.5">
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>通知 Webhook URL</label>
<input value={form.notifyWebhookURL} onChange={(e) => set('notifyWebhookURL', e.target.value)} placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/xxx" className="w-full px-3 py-2 text-[13px] rounded-md border outline-none font-mono" style={inputStyle} />
</div>
</div>
</div>
@@ -1,287 +0,0 @@
import { useState } from 'react';
import { Plus, ChevronDown, ChevronRight, ChevronLeft, Box, Layers, GitBranch, Container, Terminal, Shield, Package, BookOpen, X } from 'lucide-react';
import type { PipelineStage } from '@/types/pipeline';
import StageCard from './StageCard';
import { toast } from 'sonner';
interface TektonTask {
id: string;
name: string;
description: string;
icon: typeof Box;
category: 'source' | 'build' | 'test' | 'security' | 'deploy' | 'gitops';
params: { name: string; type: string; default?: string; description: string }[];
workspaces: string[];
results?: string[];
}
const taskCatalog: TektonTask[] = [
{ id: 'tk-git-clone', name: 'git-clone', description: '从 Git 仓库克隆代码', icon: GitBranch, category: 'source', params: [{ name: 'url', type: 'string', description: 'Git 仓库 URL' }, { name: 'revision', type: 'string', default: 'main', description: '分支或标签' }], workspaces: ['source'] },
{ id: 'tk-maven', name: 'maven', description: 'Maven 构建 Java 项目', icon: Package, category: 'build', params: [{ name: 'goals', type: 'array', default: 'clean package', description: 'Maven 目标' }, { name: 'settings', type: 'string', description: 'settings.xml 路径' }], workspaces: ['source', 'm2-cache'] },
{ id: 'tk-npm', name: 'npm', description: 'npm install + build', icon: Package, category: 'build', params: [{ name: 'script', type: 'string', default: 'build', description: 'npm 脚本' }], workspaces: ['source'] },
{ id: 'tk-go-build', name: 'go-build', description: 'Go 编译', icon: Package, category: 'build', params: [{ name: 'package', type: 'string', description: '包路径' }, { name: 'flags', type: 'string', description: '编译参数' }], workspaces: ['source'] },
{ id: 'tk-kaniko', name: 'kaniko-build', description: '无需特权构建 Docker 镜像', icon: Container, category: 'build', params: [{ name: 'dockerfile', type: 'string', default: 'Dockerfile', description: 'Dockerfile 路径' }, { name: 'image', type: 'string', description: '目标镜像地址' }], workspaces: ['source'], results: ['IMAGE_DIGEST'] },
{ id: 'tk-trivy', name: 'trivy-scan', description: '容器镜像漏洞扫描', icon: Shield, category: 'security', params: [{ name: 'image', type: 'string', description: '待扫描镜像' }, { name: 'severity', type: 'string', default: 'HIGH,CRITICAL', description: '严重等级' }], workspaces: [] },
{ id: 'tk-unit-test', name: 'unit-test', description: '运行单元测试', icon: Terminal, category: 'test', params: [{ name: 'test-command', type: 'string', description: '测试命令' }], workspaces: ['source'] },
{ id: 'tk-kubectl', name: 'kubectl-deploy', description: 'kubectl apply 部署', icon: Layers, category: 'deploy', params: [{ name: 'manifest', type: 'string', description: 'manifest 路径' }, { name: 'namespace', type: 'string', description: '目标命名空间' }], workspaces: ['source'] },
{ id: 'tk-update-manifest', name: 'update-manifest', description: '更新 GitOps 仓库镜像标签', icon: BookOpen, category: 'gitops', params: [{ name: 'repo', type: 'string', description: 'GitOps 仓库' }, { name: 'image', type: 'string', description: '新镜像地址' }, { name: 'path', type: 'string', description: 'manifest 路径' }], workspaces: ['source'] },
{ id: 'tk-helm-upgrade', name: 'helm-upgrade', description: 'Helm 部署/升级', icon: Layers, category: 'deploy', params: [{ name: 'release', type: 'string', description: 'Release 名称' }, { name: 'chart', type: 'string', description: 'Chart 路径' }, { name: 'namespace', type: 'string', description: '命名空间' }], workspaces: ['source'] },
];
const categoryConfig: Record<string, { label: string; color: string }> = {
source: { label: '源码', color: '#0969da' },
build: { label: '构建', color: '#1a7f37' },
test: { label: '测试', color: '#9a6700' },
security: { label: '安全', color: '#cf222e' },
deploy: { label: '部署', color: '#8250df' },
gitops: { label: 'GitOps', color: '#0e7c7b' },
};
interface PipelineStageEditorProps {
stages: PipelineStage[];
selectedTaskId: string | null;
onSelectTask: (taskId: string) => void;
onUpdateStages: (stages: PipelineStage[]) => void;
}
export default function PipelineStageEditor({
stages,
selectedTaskId,
onSelectTask,
onUpdateStages,
}: PipelineStageEditorProps) {
const [catalogOpen, setCatalogOpen] = useState(true);
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [showWorkspaceConfig, setShowWorkspaceConfig] = useState(false);
const handleMoveStage = (index: number, direction: 'up' | 'down') => {
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= stages.length) return;
const newStages = [...stages];
[newStages[index], newStages[newIndex]] = [newStages[newIndex], newStages[index]];
onUpdateStages(newStages);
};
const handleDeleteStage = (index: number) => {
const newStages = stages.filter((_, i) => i !== index);
onUpdateStages(newStages);
};
const handleUpdateStageName = (index: number, name: string) => {
const newStages = stages.map((s, i) => (i === index ? { ...s, name } : s));
onUpdateStages(newStages);
};
const handleAddStage = () => {
const newStage: PipelineStage = {
id: `stage-${Date.now()}`,
name: '新阶段',
type: 'custom',
tasks: [],
};
onUpdateStages([...stages, newStage]);
};
const addTaskFromCatalog = (task: TektonTask) => {
// Add to last stage or create new stage
if (stages.length === 0) {
handleAddStage();
return;
}
const lastStageIndex = stages.length - 1;
const newTask: any = {
id: `${task.id}-${Date.now()}`,
name: task.name,
type: (task.category === 'deploy' || task.category === 'gitops' ? 'deploy' : task.category === 'build' ? 'build' : 'custom') as 'build' | 'deploy' | 'test' | 'custom',
icon: task.category,
config: {
params: task.params.reduce((acc, p) => ({ ...acc, [p.name]: p.default || '' }), {} as Record<string, string>),
workspaces: task.workspaces.reduce((acc, w) => ({ ...acc, [w]: w }), {} as Record<string, string>),
results: task.results || [],
},
};
const newStages = stages.map((s, i) =>
i === lastStageIndex ? { ...s, tasks: [...s.tasks, newTask] } : s
);
onUpdateStages(newStages);
toast.success(`已添加 Task: ${task.name}`, { description: `${task.workspaces.length} 个 Workspace` });
};
const filteredTasks = selectedCategory === 'all' ? taskCatalog : taskCatalog.filter(t => t.category === selectedCategory);
return (
<div className="min-h-full flex" style={{ background: 'var(--canvas-bg)' }}>
{/* Task Catalog Sidebar */}
<div
className="border-r flex-shrink-0 transition-all duration-300 overflow-hidden"
style={{
width: catalogOpen ? '260px' : '0px',
borderColor: 'var(--border-color)',
background: 'var(--panel-bg)',
}}
>
<div className="w-[260px] h-full flex flex-col">
<div className="px-3 py-2.5 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
<span className="text-[12px] font-semibold" style={{ color: 'var(--text-primary)' }}>Task 任务库</span>
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>Tekton</span>
</div>
<div className="p-2 border-b" style={{ borderColor: 'var(--border-color)' }}>
<div className="flex items-center gap-1 flex-wrap">
<button onClick={() => setSelectedCategory('all')}
className="text-[10px] px-2 py-[3px] rounded font-medium transition-colors"
style={{ background: selectedCategory === 'all' ? 'var(--accent-blue)' : 'var(--panel-bg-secondary)', color: selectedCategory === 'all' ? '#fff' : 'var(--text-tertiary)' }}>
全部
</button>
{Object.entries(categoryConfig).map(([key, cfg]) => (
<button key={key} onClick={() => setSelectedCategory(key)}
className="text-[10px] px-2 py-[3px] rounded font-medium transition-colors"
style={{ background: selectedCategory === key ? cfg.color : 'var(--panel-bg-secondary)', color: selectedCategory === key ? '#fff' : 'var(--text-tertiary)' }}>
{cfg.label}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1">
{filteredTasks.map(task => {
const Icon = task.icon;
const cat = categoryConfig[task.category];
return (
<div key={task.id}
onClick={() => addTaskFromCatalog(task)}
className="p-2.5 rounded-md border cursor-pointer hover:bg-black/[0.02] transition-colors group"
style={{ borderColor: 'var(--border-color-light)' }}>
<div className="flex items-center gap-2 mb-1">
<Icon className="w-3.5 h-3.5" style={{ color: cat.color }} />
<span className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>{task.name}</span>
<span className="text-[9px] px-1 py-[1px] rounded font-medium ml-auto" style={{ background: cat.color + '15', color: cat.color }}>{cat.label}</span>
</div>
<p className="text-[10px] mb-1" style={{ color: 'var(--text-secondary)' }}>{task.description}</p>
<div className="flex items-center gap-1.5">
{task.workspaces.length > 0 && (
<span className="text-[9px] flex items-center gap-0.5" style={{ color: 'var(--text-tertiary)' }}>
<Box className="w-2.5 h-2.5" />{task.workspaces.length} ws
</span>
)}
{task.params.length > 0 && (
<span className="text-[9px]" style={{ color: 'var(--text-tertiary)' }}>{task.params.length} params</span>
)}
{task.results && (
<span className="text-[9px]" style={{ color: 'var(--text-tertiary)' }}>{task.results.length} results</span>
)}
</div>
</div>
);
})}
</div>
<div className="px-3 py-2 border-t text-[10px]" style={{ borderColor: 'var(--border-color)', color: 'var(--text-tertiary)' }}>
点击 Task 添加到流水线末尾阶段
</div>
</div>
</div>
{/* Main Editor */}
<div className="flex-1 min-w-0">
<div className="px-6 py-5">
{/* Pipeline metadata bar */}
<div className="flex items-center justify-between mb-5 w-full" style={{ maxWidth: '720px', marginLeft: 'auto', marginRight: 'auto' }}>
<div className="flex items-center gap-3">
<button
onClick={() => setCatalogOpen(!catalogOpen)}
className="flex items-center gap-1 px-2 py-[5px] text-[11px] font-medium rounded-md border"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
title={catalogOpen ? '收起任务库' : '展开任务库'}
>
{catalogOpen ? <ChevronLeft className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
Task 库
</button>
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold tracking-tight" style={{ color: 'var(--text-primary)' }}>
流水线阶段
</span>
<span className="text-[11px] font-medium px-1.5 py-0.5 rounded-full" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
{stages.length}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowWorkspaceConfig(!showWorkspaceConfig)}
className="flex items-center gap-1 px-2 py-[5px] text-[11px] font-medium rounded-md border"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
>
<Box className="w-3 h-3" />Workspace
</button>
<button
onClick={handleAddStage}
className="flex items-center gap-1.5 px-3 py-[6px] text-[12px] font-medium rounded-md text-white transition-all hover:brightness-110 active:scale-[0.98]"
style={{ background: 'var(--accent-blue)' }}
>
<Plus className="w-3.5 h-3.5" />添加阶段
</button>
</div>
</div>
{/* Workspace config panel */}
{showWorkspaceConfig && (
<div className="mb-4 rounded-lg border p-3 mx-auto" style={{ maxWidth: '720px', borderColor: 'var(--border-color)', background: 'var(--panel-bg)' }}>
<div className="flex items-center justify-between mb-2">
<span className="text-[12px] font-semibold" style={{ color: 'var(--text-primary)' }}>Pipeline Workspace 配置</span>
<button onClick={() => setShowWorkspaceConfig(false)} className="p-0.5 rounded hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }}><X className="w-3.5 h-3.5" /></button>
</div>
<div className="grid grid-cols-3 gap-2">
{['source', 'm2-cache', 'docker-config'].map(ws => (
<div key={ws} className="flex items-center gap-2 p-2 rounded-md border text-[11px]" style={{ borderColor: 'var(--border-color-light)' }}>
<Box className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
<span style={{ color: 'var(--text-primary)' }}>{ws}</span>
<select className="ml-auto text-[10px] rounded border outline-none px-1 py-[2px]" style={{ borderColor: 'var(--border-color-light)', background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>
<option>EmptyDir</option><option>PVC</option><option>ConfigMap</option><option>Secret</option>
</select>
</div>
))}
</div>
</div>
)}
{/* Stage list */}
<div className="flex flex-col items-center" style={{ maxWidth: '720px', marginLeft: 'auto', marginRight: 'auto' }}>
{stages.map((stage, index) => (
<div key={stage.id} className="w-full flex flex-col items-center">
<StageCard
stage={stage}
index={index}
isFirst={index === 0}
isLast={index === stages.length - 1}
selectedTaskId={selectedTaskId}
onSelectTask={onSelectTask}
onMoveUp={() => handleMoveStage(index, 'up')}
onMoveDown={() => handleMoveStage(index, 'down')}
onDelete={() => handleDeleteStage(index)}
onUpdateName={(name) => handleUpdateStageName(index, name)}
/>
{index < stages.length - 1 && (
<div className="flex flex-col items-center py-2">
<div className="flex flex-col items-center gap-[2px]">
<div className="w-px h-3" style={{ background: 'var(--border-color)' }} />
<ChevronDown className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
<div className="w-px h-3" style={{ background: 'var(--border-color)' }} />
</div>
</div>
)}
</div>
))}
</div>
{stages.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 rounded-lg border-2 border-dashed mx-auto" style={{ maxWidth: '720px', borderColor: 'var(--border-color-light)', background: 'var(--panel-bg)' }}>
<div className="w-12 h-12 rounded-full flex items-center justify-center mb-3" style={{ background: 'var(--panel-bg-secondary)' }}>
<Plus className="w-5 h-5" style={{ color: 'var(--text-tertiary)' }} />
</div>
<p className="text-[13px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>暂无阶段</p>
<p className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>从左侧 Task 库选择任务,或添加阶段</p>
</div>
)}
</div>
</div>
</div>
);
}
+145 -382
View File
@@ -1,400 +1,163 @@
import { useState, useEffect, useRef } from 'react';
import {
CheckCircle2,
XCircle,
Loader2,
Clock,
RotateCcw,
Eye,
GitCommit,
Hand,
Timer,
} from 'lucide-react';
import type { ExecutionRecord } from '@/types/pipeline';
import { useState } from 'react';
import { CheckCircle2, XCircle, Loader2, Clock, RotateCcw, ExternalLink, Rocket } from 'lucide-react';
import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api';
import { usePipelineLogs } from '@/hooks/usePipelineLogs';
const mockExecutions: ExecutionRecord[] = [
{
id: 5,
status: 'running',
trigger: 'Manual',
triggerBy: 'admin',
duration: '1分12秒',
timestamp: '2024-06-20 15:45:00',
pipelineName: 'aiops-backend',
},
{
id: 4,
status: 'success',
trigger: 'Git Push',
triggerBy: 'system',
duration: '3分22秒',
timestamp: '2024-06-20 14:30:15',
pipelineName: 'aiops-backend',
},
{
id: 3,
status: 'failed',
trigger: 'Manual',
triggerBy: 'admin',
duration: '45秒',
timestamp: '2024-06-20 13:20:00',
pipelineName: 'aiops-backend',
},
{
id: 2,
status: 'success',
trigger: 'Cron',
triggerBy: 'system',
duration: '5分10秒',
timestamp: '2024-06-20 02:00:00',
pipelineName: 'aiops-backend',
},
{
id: 1,
status: 'success',
trigger: 'Git Push',
triggerBy: 'system',
duration: '4分55秒',
timestamp: '2024-06-19 18:45:30',
pipelineName: 'aiops-backend',
},
];
const fullLogLines: string[] = [
'[15:45:00] ===== 流水线开始执行: aiops-backend =====',
'[15:45:00] 触发方式: 手动 (admin)',
'',
'[15:45:01] ===== 阶段: 拉取代码 =====',
'[15:45:01] > Git 拉取',
'[15:45:03] Cloning into \'aiops\'...',
'[15:45:08] Already up to date. commit: 7a3f2e1',
'[15:45:08] \u2713 Git 拉取 完成 (7s)',
'',
'[15:45:08] ===== 阶段: 构建镜像 =====',
'[15:45:09] > Docker 构建 & 推送',
'[15:45:10] === 开始构建 Docker 镜像 ===',
'[15:45:10] 镜像地址: registry.example.com/aiops/aiops-backend:202506201545',
'[15:45:11] Docker登录...',
'[15:45:12] Login Succeeded',
'[15:45:15] Building image...',
'[15:45:45] Successfully built 9a8b7c6d5e4f',
'[15:45:46] 推送镜像...',
'[15:46:02] latest: digest: sha256:abc123... size: 2847',
'[15:46:02] \u2713 Docker 构建 & 推送 完成 (53s)',
'[15:46:02] > 单元测试',
'[15:46:03] go test -v -race -coverprofile=coverage.out ./...',
'[15:46:15] PASS: TestUserLogin (0.23s)',
'[15:46:15] PASS: TestDataQuery (1.12s)',
'[15:46:18] PASS: TestWebhookHandler (0.45s)',
'[15:46:20] coverage: 82.3% of statements',
'[15:46:20] \u2713 单元测试 完成 (18s) \u2014 覆盖率: 82.3%',
'',
'[15:46:20] ===== 阶段: K8s 部署 =====',
'[15:46:21] > K8s 滚动更新',
'[15:46:22] 更新镜像: registry.example.com/aiops/aiops-backend:202506201545',
'[15:46:25] deployment.apps/aiops-backend image updated',
'[15:46:25] 等待滚动更新...',
'[15:46:30] [=====================> ] 2/3 pods ready',
'[15:46:35] [========================>] 3/3 pods ready',
'[15:46:36] deployment "aiops-backend" successfully rolled out',
'[15:46:36] Pod状态:',
'[15:46:36] NAME READY STATUS',
'[15:46:36] aiops-backend-7a3f2e1-x1 1/1 Running',
'[15:46:36] aiops-backend-7a3f2e1-x2 1/1 Running',
'[15:46:36] aiops-backend-7a3f2e1-x3 1/1 Running',
'[15:46:36] \u2713 K8s 滚动更新 完成 (15s)',
'',
'[15:46:37] ===== 阶段: 后置操作 =====',
'[15:46:37] > 刷新CDN',
'[15:46:37] 刷新CDN缓存...',
'[15:46:38] {"code": 200, "message": "refresh submitted"}',
'[15:46:38] \u2713 刷新CDN 完成 (1s)',
'',
'[15:46:38] ===== 流水线执行完成 =====',
'[15:46:38] 总耗时: 1分38秒',
'[15:46:38] 结果: \u2713 成功',
];
function getStatusIcon(status: string, className = 'w-5 h-5') {
switch (status) {
case 'success':
return <CheckCircle2 className={`${className} text-green-500`} />;
case 'failed':
return <XCircle className={`${className} text-red-500`} />;
case 'running':
return <Loader2 className={`${className} text-blue-500 animate-spin`} />;
default:
return <Clock className={`${className} text-gray-400`} />;
}
interface Props {
pipelineId: number;
}
function getStatusText(status: string) {
switch (status) {
case 'success':
return '成功';
case 'failed':
return '失败';
case 'running':
return '正在执行';
default:
return '等待中';
}
const statusMap: Record<string, { color: string; bg: string; label: string }> = {
success: { color: '#1a7f37', bg: '#dafbe1', label: '成功' },
failed: { color: '#cf222e', bg: '#ffebe9', label: '失败' },
running: { color: '#0969da', bg: '#ddf4ff', label: '执行中' },
pending: { color: '#9a6700', bg: '#fff8c5', label: '排队中' },
queued: { color: '#9a6700', bg: '#fff8c5', label: '排队中' },
cancelled: { color: '#6e7781', bg: '#f6f8fa', label: '已取消' },
};
function fmtDuration(ms: number): string {
if (!ms) return '-';
const s = Math.floor(ms / 1000);
if (s < 60) return `${(ms / 1000).toFixed(1)}s`;
if (s < 3600) return `${Math.floor(s / 60)}分${s % 60}秒`;
return `${Math.floor(s / 3600)}小时${Math.floor((s % 3600) / 60)}分`;
}
function getTriggerIcon(trigger: string, className = 'w-3.5 h-3.5') {
switch (trigger) {
case 'Git Push':
return <GitCommit className={className} />;
case 'Manual':
return <Hand className={className} />;
case 'Cron':
return <Timer className={className} />;
default:
return <Clock className={className} />;
}
}
export default function ReleasePanel({ pipelineId }: Props) {
const queryClient = useQueryClient();
const [expandedRunId, setExpandedRunId] = useState<string | null>(null);
function LogLine({ line }: { line: string }) {
if (!line) {
return <div className="h-4" />;
}
const { data: runs = [], isLoading } = useQuery({
queryKey: ['runs', { pipelineId }],
queryFn: () => apiClient.listRuns({ pipelineId }),
});
const timestamp = line.match(/^\[(\d{2}:\d{2}:\d{2})\]/)?.[0] || '';
const content = line.slice(timestamp.length);
const { data: detail } = useQuery({
queryKey: ['run', expandedRunId],
queryFn: () => apiClient.getRun(expandedRunId!),
enabled: !!expandedRunId,
});
// Determine color based on content
let contentColor = '#e6e6e6';
if (content.includes('\u2713') || content.includes('成功') || content.includes('PASS')) {
contentColor = '#7ee787';
} else if (content.includes('失败') || content.includes('error') || content.includes('Error')) {
contentColor = '#f85149';
} else if (content.includes('警告') || content.includes('warn') || content.includes('Waiting')) {
contentColor = '#d29922';
} else if (content.startsWith(' =====')) {
contentColor = '#79c0ff';
} else if (content.startsWith(' > ')) {
contentColor = '#d2a8ff';
}
const expandedRun = runs.find((r) => r.runId === expandedRunId);
const isRunning = expandedRun?.status === 'running' || expandedRun?.status === 'pending';
const { logs: liveLogs, connected } = usePipelineLogs(expandedRunId, !!expandedRunId && isRunning);
const rerunMutation = useMutation({
mutationFn: (runId: string) => apiClient.retryRun(runId),
onSuccess: (run) => {
toast.success(`已重新触发 ${run.runId}`);
queryClient.invalidateQueries({ queryKey: ['runs'] });
},
onError: (err: Error) => toast.error('触发失败', { description: err.message }),
});
const logs = isRunning ? liveLogs : (detail?.logs || []);
const stages = detail?.stages || [];
return (
<div className="font-mono text-sm leading-relaxed whitespace-pre-wrap">
{timestamp && (
<span style={{ color: '#8b949e' }}>{timestamp}</span>
)}
<span style={{ color: contentColor }}>{content}</span>
</div>
);
}
export default function ReleasePanel() {
const [selectedExecutionId, setSelectedExecutionId] = useState(5);
const [displayedLines, setDisplayedLines] = useState<string[]>([]);
const [isAnimating, setIsAnimating] = useState(false);
const logEndRef = useRef<HTMLDivElement>(null);
const animatingRef = useRef(false);
const selectedExecution = mockExecutions.find((e) => e.id === selectedExecutionId);
const isRunning = selectedExecution?.status === 'running';
// Animate log lines for running execution
useEffect(() => {
if (isRunning) {
setDisplayedLines([]);
setIsAnimating(true);
animatingRef.current = true;
let index = 0;
const interval = setInterval(() => {
if (!animatingRef.current) {
clearInterval(interval);
return;
}
if (index < fullLogLines.length) {
setDisplayedLines((prev) => [...prev, fullLogLines[index]]);
index++;
} else {
setIsAnimating(false);
animatingRef.current = false;
clearInterval(interval);
}
}, 80); // Each line appears every 80ms
return () => {
animatingRef.current = false;
clearInterval(interval);
};
} else {
// Show full log immediately for completed executions
setDisplayedLines(fullLogLines);
setIsAnimating(false);
}
}, [selectedExecutionId, isRunning]);
// Auto-scroll to bottom
useEffect(() => {
if (logEndRef.current) {
logEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [displayedLines]);
return (
<div className="flex h-full" style={{ background: 'var(--panel-bg)' }}>
{/* Left: Execution History (40%) */}
<div
className="w-[40%] border-r flex flex-col overflow-hidden"
style={{
borderColor: 'var(--border-color)',
minWidth: '340px',
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 border-b"
style={{ borderColor: 'var(--border-color)' }}
>
<span
className="text-sm font-semibold"
style={{ color: 'var(--text-primary)' }}
>
执行记录
</span>
<span className="text-xs px-2 py-0.5 rounded-full" style={{ color: 'var(--text-secondary)', background: 'var(--panel-bg-secondary)' }}>
{mockExecutions.length} 条记录
</span>
</div>
{/* List */}
<div className="flex-1 overflow-y-auto custom-scrollbar">
{mockExecutions.map((execution) => (
<div
key={execution.id}
onClick={() => setSelectedExecutionId(execution.id)}
className={`px-4 py-3 border-b cursor-pointer transition-colors ${
selectedExecutionId === execution.id ? 'bg-blue-50/50' : 'hover:bg-[var(--panel-bg-secondary)]'
}`}
style={{ borderColor: 'var(--border-color-light)' }}
>
<div className="flex items-start gap-3">
{getStatusIcon(execution.status)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span
className="text-sm font-semibold"
style={{ color: 'var(--text-primary)' }}
>
#{execution.id}
</span>
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{
background:
execution.status === 'success'
? '#dafbe1'
: execution.status === 'failed'
? '#ffebe9'
: execution.status === 'running'
? '#ddf4ff'
: '#f6f8fa',
color:
execution.status === 'success'
? '#1a7f37'
: execution.status === 'failed'
? '#cf222e'
: execution.status === 'running'
? '#0969da'
: '#6e7781',
}}
>
{getStatusText(execution.status)}
</span>
</div>
<div className="flex items-center gap-3 text-xs" style={{ color: 'var(--text-secondary)' }}>
<span className="flex items-center gap-1">
{getTriggerIcon(execution.trigger)}
{execution.trigger}
</span>
<span>{execution.triggerBy}</span>
</div>
<div className="flex items-center justify-between mt-1.5">
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
{execution.duration}
</span>
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
{execution.timestamp}
</span>
</div>
</div>
<div className="flex flex-col gap-1">
<button
className="p-1 rounded hover:bg-[var(--panel-bg-secondary)] transition-colors"
title="重新执行"
onClick={(e) => {
e.stopPropagation();
}}
>
<RotateCcw className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
</button>
<button
className="p-1 rounded hover:bg-[var(--panel-bg-secondary)] transition-colors"
title="查看日志"
onClick={(e) => {
e.stopPropagation();
setSelectedExecutionId(execution.id);
}}
>
<Eye className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
</button>
</div>
</div>
</div>
))}
</div>
</div>
{/* Right: Log Viewer (60%) */}
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
{/* Log header */}
<div
className="flex items-center justify-between px-4 py-3 border-b"
style={{ borderColor: 'var(--border-color)' }}
>
<div className="tab-content-fade w-full px-6 py-6 max-w-4xl">
<div className="rounded-lg border shadow-sm" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-center justify-between px-5 py-3.5 border-b" style={{ borderColor: 'var(--border-color)' }}>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
执行日志
</span>
{isRunning && isAnimating && (
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 animate-pulse">
实时输出中...
</span>
)}
<Rocket className="w-4.5 h-4.5" style={{ color: 'var(--accent-blue)' }} />
<h3 className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>发布历史</h3>
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{runs.length} 次执行</span>
</div>
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
{selectedExecution?.pipelineName} #{selectedExecutionId}
</span>
</div>
{/* Log content */}
<div
className="flex-1 overflow-y-auto custom-scrollbar px-4 py-3"
style={{ background: '#1e1e1e' }}
>
{displayedLines.map((line, index) => (
<LogLine key={`${selectedExecutionId}-${index}`} line={line} />
))}
{isAnimating && (
<div className="flex items-center gap-2 mt-2">
<div className="w-2 h-2 rounded-full bg-green-400 animate-pulse" />
<span className="text-xs" style={{ color: '#8b949e' }}>
等待新输出...
</span>
{isLoading && (
<div className="py-10 text-center" style={{ color: 'var(--text-tertiary)' }}>加载中…</div>
)}
{!isLoading && runs.length === 0 && (
<div className="py-10 text-center text-[12px]" style={{ color: 'var(--text-tertiary)' }}>暂无发布记录,点击顶部「保存并运行」执行一次</div>
)}
{runs.map((r) => {
const st = statusMap[r.status] || statusMap.pending;
const isExpanded = expandedRunId === r.runId;
return (
<div key={r.runId} className="border-b last:border-b-0" style={{ borderColor: 'var(--border-color-light)' }}>
<div
className="flex items-center gap-3 px-5 py-2.5 cursor-pointer hover:bg-black/[0.015]"
onClick={() => setExpandedRunId(isExpanded ? null : r.runId)}
>
{r.status === 'running' ? (
<Loader2 className="w-4 h-4 animate-spin" style={{ color: '#0969da' }} />
) : r.status === 'success' ? (
<CheckCircle2 className="w-4 h-4" style={{ color: '#1a7f37' }} />
) : (
<XCircle className="w-4 h-4" style={{ color: r.status === 'failed' ? '#cf222e' : '#6e7781' }} />
)}
<span className="font-mono text-[11px] w-36" style={{ color: 'var(--text-tertiary)' }}>{r.runId}</span>
<span className="text-[12px]" style={{ color: 'var(--text-primary)' }}>{r.branch}</span>
<span className="text-[10px] px-1.5 py-[1px] rounded" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>{r.env}</span>
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: st.bg, color: st.color }}>{st.label}</span>
<span className="text-[11px] flex items-center gap-1 ml-auto" style={{ color: 'var(--text-tertiary)' }}>
<Clock className="w-3 h-3" />{fmtDuration(r.durationMs)}
</span>
<span className="text-[11px] w-36 text-right" style={{ color: 'var(--text-tertiary)' }}>
{new Date(r.startedAt).toLocaleString('zh-CN', { hour12: false })}
</span>
{(r.status === 'failed' || r.status === 'cancelled') && (
<button
onClick={(e) => { e.stopPropagation(); rerunMutation.mutate(r.runId); }}
className="p-1 rounded hover:bg-black/5"
style={{ color: 'var(--accent-blue)' }}
title="重新执行"
>
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
{isExpanded && (
<div className="px-5 pb-4">
{r.externalUrl && (
<a href={r.externalUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-[11px] mb-2 font-mono" style={{ color: 'var(--accent-blue)' }}>
<ExternalLink className="w-3 h-3" />Jenkins 构建 #{r.buildNumber}
</a>
)}
{stages.length > 0 && (
<div className="flex items-center gap-2 flex-wrap mb-2">
{stages.map((s: any) => {
const sst = statusMap[s.status] || statusMap.pending;
return (
<span key={s.id} className="text-[10px] px-2 py-[2px] rounded-md font-medium" style={{ background: sst.bg, color: sst.color }}>
{s.name}
</span>
);
})}
</div>
)}
<div className="rounded-md border overflow-hidden" style={{ borderColor: 'var(--border-color-light)', background: 'var(--panel-bg-secondary)' }}>
<div className="px-3 py-1.5 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-color-light)' }}>
<span className="text-[11px] font-semibold" style={{ color: 'var(--text-primary)' }}>执行日志</span>
{isRunning && (
<span className="text-[9px] px-1.5 py-[1px] rounded animate-pulse" style={{ background: '#fff8c5', color: '#9a6700' }}>
{connected ? 'LIVE' : '连接中…'}
</span>
)}
</div>
<div className="max-h-64 overflow-y-auto custom-scrollbar px-3 py-2 font-mono text-[11px] leading-5">
{logs.length === 0 && <div style={{ color: 'var(--text-tertiary)' }}>暂无日志</div>}
{logs.map((l: any, i: number) => (
<div key={i} className="flex gap-2">
<span className="flex-shrink-0" style={{ color: 'var(--text-tertiary)' }}>
{l.timestamp ? new Date(l.timestamp).toLocaleTimeString('zh-CN', { hour12: false }) : ''}
</span>
<span style={{ color: l.level === 'error' ? '#cf222e' : l.level === 'warn' ? '#9a6700' : 'var(--text-secondary)' }}>{l.message}</span>
</div>
))}
</div>
</div>
</div>
)}
</div>
)}
<div ref={logEndRef} />
</div>
);
})}
</div>
</div>
);
File diff suppressed because it is too large. Load diff
-237
View File
@@ -1,237 +0,0 @@
import { useState, useCallback, useRef, useEffect } from 'react';
interface ScriptEditorProps {
value: string;
onChange: (value: string) => void;
language: 'shell' | 'python';
theme?: 'light' | 'dark';
}
// Basic syntax highlighting patterns
const shellKeywords = ['if', 'then', 'else', 'elif', 'fi', 'for', 'while', 'do', 'done', 'case', 'esac', 'function', 'return', 'exit', 'echo', 'printf', 'read', 'source', 'export', 'unset', 'local', 'shift', 'break', 'continue', 'in', 'trap'];
const pythonKeywords = ['and', 'as', 'assert', 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except', 'False', 'finally', 'for', 'from', 'global', 'if', 'import', 'in', 'is', 'lambda', 'None', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', 'True', 'try', 'while', 'with', 'yield'];
const builtinFunctions = ['print', 'len', 'range', 'str', 'int', 'float', 'list', 'dict', 'set', 'tuple', 'type', 'isinstance', 'hasattr', 'getattr', 'open', 'input', 'map', 'filter', 'zip', 'enumerate', 'sorted', 'reversed', 'sum', 'min', 'max', 'abs', 'round', 'pow', 'divmod', 'chr', 'ord', 'hex', 'bin', 'oct', 'format'];
// Dark theme syntax colors
const darkSyntaxColors = {
comment: '#6a9955',
string: '#ce9178',
keyword: '#569cd6',
function: '#dcdcaa',
number: '#b5cea8',
default: '#d4d4d4',
};
// Light theme syntax colors
const lightSyntaxColors = {
comment: '#1a7f37',
string: '#cf222e',
keyword: '#8250df',
function: '#8250df',
number: '#9a6700',
default: '#1f2328',
};
function highlightLine(line: string, language: 'shell' | 'python', isDark: boolean): string {
let html = '';
let remaining = line;
const colors = isDark ? darkSyntaxColors : lightSyntaxColors;
const keywords = language === 'shell' ? shellKeywords : pythonKeywords;
const kwPattern = new RegExp(`^(\\s*)(${keywords.join('|')})(?![a-zA-Z0-9_])`);
const funcPattern = /^(\s*)([a-zA-Z_][a-zA-Z0-9_]*)(\s*\()/;
const stringPattern = /^(["'])(.*?)(\1)/;
const commentPattern = language === 'shell' ? /^(#.*)/ : /^(#.*)/;
const numberPattern = /^([0-9]+\.?[0-9]*)/;
const wordPattern = /^([a-zA-Z_][a-zA-Z0-9_]*)/;
const spacePattern = /^(\s+)/;
const otherPattern = /^(.)/;
while (remaining.length > 0) {
// Check for comment first
const commentMatch = remaining.match(commentPattern);
if (commentMatch) {
html += `<span style="color: ${colors.comment}; font-style: italic;">${escapeHtml(commentMatch[0])}</span>`;
remaining = remaining.slice(commentMatch[0].length);
continue;
}
// String literals
const stringMatch = remaining.match(stringPattern);
if (stringMatch) {
html += `<span style="color: ${colors.string};">${escapeHtml(stringMatch[0])}</span>`;
remaining = remaining.slice(stringMatch[0].length);
continue;
}
// Function calls
const funcMatch = remaining.match(funcPattern);
if (funcMatch && language === 'python' && builtinFunctions.includes(funcMatch[2])) {
html += `${escapeHtml(funcMatch[1])}<span style="color: ${colors.function};">${escapeHtml(funcMatch[2])}</span>${escapeHtml(funcMatch[3])}`;
remaining = remaining.slice(funcMatch[0].length);
continue;
}
// Keywords
const kwMatch = remaining.match(kwPattern);
if (kwMatch) {
html += `${escapeHtml(kwMatch[1])}<span style="color: ${colors.keyword}; font-weight: 600;">${escapeHtml(kwMatch[2])}</span>`;
remaining = remaining.slice(kwMatch[0].length);
continue;
}
// Numbers
const numMatch = remaining.match(numberPattern);
if (numMatch) {
html += `<span style="color: ${colors.number};">${escapeHtml(numMatch[0])}</span>`;
remaining = remaining.slice(numMatch[0].length);
continue;
}
// Words
const wordMatch = remaining.match(wordPattern);
if (wordMatch) {
html += escapeHtml(wordMatch[0]);
remaining = remaining.slice(wordMatch[0].length);
continue;
}
// Whitespace
const spaceMatch = remaining.match(spacePattern);
if (spaceMatch) {
html += escapeHtml(spaceMatch[0]);
remaining = remaining.slice(spaceMatch[0].length);
continue;
}
// Other characters
const otherMatch = remaining.match(otherPattern);
if (otherMatch) {
html += escapeHtml(otherMatch[0]);
remaining = remaining.slice(otherMatch[0].length);
continue;
}
break;
}
return html || '&nbsp;';
}
function escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
export default function ScriptEditor({ value, onChange, language, theme = 'light' }: ScriptEditorProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const highlightRef = useRef<HTMLDivElement>(null);
const [focused, setFocused] = useState(false);
const isDark = theme === 'dark';
const lines = value.split('\n');
const lineNumbers = lines.map((_, i) => i + 1).join('\n');
const highlightedHtml = lines
.map((line) => highlightLine(line, language, isDark))
.join('<br>');
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Tab') {
e.preventDefault();
const target = e.currentTarget;
const start = target.selectionStart;
const end = target.selectionEnd;
const newValue = value.substring(0, start) + ' ' + value.substring(end);
onChange(newValue);
requestAnimationFrame(() => {
target.selectionStart = target.selectionEnd = start + 2;
});
}
},
[value, onChange]
);
const handleScroll = useCallback(() => {
if (textareaRef.current && highlightRef.current) {
highlightRef.current.scrollTop = textareaRef.current.scrollTop;
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft;
}
}, []);
useEffect(() => {
handleScroll();
}, [value, handleScroll]);
return (
<div
className={`relative rounded-md border overflow-hidden script-editor ${
focused ? 'ring-1' : ''
}`}
style={{
borderColor: isDark ? '#333333' : 'var(--border-color)',
minHeight: '200px',
'--tw-ring-color': 'rgba(9, 105, 218, 0.25)',
background: isDark ? '#1e1e1e' : 'var(--panel-bg)',
} as React.CSSProperties}
>
<div className="flex" style={{ minHeight: '200px' }}>
{/* Line numbers */}
<div
className="flex-shrink-0 py-3 px-2 text-right select-none"
style={{
background: isDark ? '#1e1e1e' : 'var(--panel-bg-secondary)',
color: isDark ? '#858585' : 'var(--text-tertiary)',
fontSize: '13px',
lineHeight: '1.6',
minWidth: '40px',
borderRight: `1px solid ${isDark ? '#333333' : 'var(--border-color-light)'}`,
}}
>
{lineNumbers.split('\n').map((num, i) => (
<div key={i}>{num}</div>
))}
</div>
{/* Editor area */}
<div className="relative flex-1" style={{ minHeight: '200px' }}>
{/* Highlight layer */}
<div
ref={highlightRef}
className="absolute inset-0 py-3 px-3 pointer-events-none overflow-hidden whitespace-pre"
style={{
color: isDark ? '#d4d4d4' : 'var(--text-primary)',
fontSize: '13px',
lineHeight: '1.6',
}}
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
/>
{/* Textarea layer */}
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onScroll={handleScroll}
spellCheck={false}
className="absolute inset-0 w-full h-full py-3 px-3 resize-none bg-transparent script-editor"
style={{
color: 'transparent',
caretColor: isDark ? '#d4d4d4' : 'var(--text-primary)',
fontSize: '13px',
lineHeight: '1.6',
outline: 'none',
}}
/>
</div>
</div>
</div>
);
}
+36 -17
View File
@@ -1,9 +1,9 @@
import {
GitBranch,
Server,
Key,
Cpu,
Wrench,
import {
GitBranch,
Server,
Key,
Cpu,
Wrench,
Download,
ChevronLeft,
ChevronRight,
@@ -15,6 +15,8 @@ import {
Puzzle,
Settings
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api';
type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'webhooks' | 'plugins' | 'settings';
@@ -61,6 +63,19 @@ const menuGroups: { label: string; items: { id: ModuleType; label: string; icon:
];
export default function Sidebar({ activeModule, onModuleChange, collapsed, onToggleCollapse }: SidebarProps) {
// 徽标数来自后端真实统计
const { data: summary } = useQuery({
queryKey: ['summary'],
queryFn: apiClient.getSummary,
refetchInterval: 15000,
});
const badgeFor = (id: ModuleType): string | null => {
if (id === 'build-nodes') return summary?.buildNodes != null ? String(summary.buildNodes) : null;
if (id === 'plugins') return summary?.plugins != null ? String(summary.plugins) : null;
return null;
};
return (
<div
className="flex flex-col border-r transition-all duration-300 ease-in-out h-full"
@@ -121,17 +136,21 @@ export default function Sidebar({ activeModule, onModuleChange, collapsed, onTog
<span className="text-[13px] font-medium truncate flex-1 text-left">
{item.label}
</span>
{item.badge && (
<span
className="text-[9px] px-1 py-[1px] rounded font-medium"
style={{
background: item.badge === 'New' ? '#ddf4ff' : 'var(--panel-bg-secondary)',
color: item.badge === 'New' ? 'var(--accent-blue)' : 'var(--text-tertiary)',
}}
>
{item.badge}
</span>
)}
{(() => {
const badge = item.badge === 'New' ? 'New' : badgeFor(item.id);
if (!badge) return null;
return (
<span
className="text-[9px] px-1 py-[1px] rounded font-medium"
style={{
background: badge === 'New' ? '#ddf4ff' : 'var(--panel-bg-secondary)',
color: badge === 'New' ? 'var(--accent-blue)' : 'var(--text-tertiary)',
}}
>
{badge}
</span>
);
})()}
</>
)}
{isActive && !collapsed && (
-190
View File
@@ -1,190 +0,0 @@
import { useState } from 'react';
import {
ChevronUp,
ChevronDown,
X,
Plus,
GitBranch,
Wrench,
Server,
FlaskConical,
Settings,
} from 'lucide-react';
import type { PipelineStage } from '@/types/pipeline';
import TaskCard from './TaskCard';
interface StageCardProps {
stage: PipelineStage;
index: number;
isFirst: boolean;
isLast: boolean;
selectedTaskId: string | null;
onSelectTask: (taskId: string) => void;
onMoveUp: () => void;
onMoveDown: () => void;
onDelete: () => void;
onUpdateName: (name: string) => void;
}
// Only use color for the LEFT BORDER — keep everything else neutral
const stageTypeConfig: Record<string, { color: string; icon: typeof GitBranch }> = {
source: { color: '#0969da', icon: GitBranch },
build: { color: '#9a6700', icon: Wrench },
deploy: { color: '#1a7f37', icon: Server },
test: { color: '#8250df', icon: FlaskConical },
custom: { color: '#6e7781', icon: Settings },
};
export default function StageCard({
stage,
index,
isFirst,
isLast,
selectedTaskId,
onSelectTask,
onMoveUp,
onMoveDown,
onDelete,
onUpdateName,
}: StageCardProps) {
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(stage.name);
const config = stageTypeConfig[stage.type] || stageTypeConfig.custom;
const IconComponent = config.icon;
const handleNameSubmit = () => {
if (editName.trim()) {
onUpdateName(editName.trim());
} else {
setEditName(stage.name);
}
setIsEditing(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleNameSubmit();
else if (e.key === 'Escape') {
setEditName(stage.name);
setIsEditing(false);
}
};
return (
<div className="w-full">
<div
className="rounded-lg overflow-hidden transition-shadow duration-200 hover:shadow-sm"
style={{
background: 'var(--panel-bg)',
border: '1px solid var(--border-color)',
borderLeft: `3px solid ${config.color}`,
}}
>
{/* Header — unified neutral background, NO colorful bg */}
<div
className="flex items-center justify-between px-4 py-2.5 border-b"
style={{
borderColor: 'var(--border-color-light)',
background: '#f8f9fa',
}}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<IconComponent className="w-3.5 h-3.5 flex-shrink-0" style={{ color: config.color }} />
{isEditing ? (
<input
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={handleNameSubmit}
onKeyDown={handleKeyDown}
autoFocus
className="text-[13px] font-semibold bg-white border rounded px-2 py-0.5 outline-none"
style={{
color: 'var(--text-primary)',
borderColor: config.color,
minWidth: '140px',
fontSize: '13px',
}}
/>
) : (
<button
onClick={() => setIsEditing(true)}
className="text-[13px] font-semibold hover:opacity-70 transition-opacity truncate text-left"
style={{ color: 'var(--text-primary)' }}
title="点击编辑"
>
阶段 {index + 1}: {stage.name}
</button>
)}
</div>
{/* Refined action buttons — smaller, tighter */}
<div className="flex items-center gap-[2px] flex-shrink-0">
<button
onClick={onMoveUp}
disabled={isFirst}
className="p-1 rounded transition-all hover:bg-black/5 disabled:opacity-25 disabled:cursor-not-allowed disabled:hover:bg-transparent"
title="上移"
>
<ChevronUp className="w-3 h-3" style={{ color: 'var(--text-secondary)' }} />
</button>
<button
onClick={onMoveDown}
disabled={isLast}
className="p-1 rounded transition-all hover:bg-black/5 disabled:opacity-25 disabled:cursor-not-allowed disabled:hover:bg-transparent"
title="下移"
>
<ChevronDown className="w-3 h-3" style={{ color: 'var(--text-secondary)' }} />
</button>
<button
onClick={onDelete}
className="p-1 rounded transition-all hover:bg-red-50"
title="删除阶段"
>
<X className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
</button>
</div>
</div>
{/* Body: Task list */}
<div className="px-2 py-1.5">
{stage.tasks.map((task) => (
<TaskCard
key={task.id}
task={task}
isSelected={selectedTaskId === task.id}
onSelect={() => onSelectTask(task.id)}
onDelete={() => {/* TODO */}}
/>
))}
{stage.tasks.length === 0 && (
<div
className="text-center py-4 rounded-md"
style={{ color: 'var(--text-tertiary)' }}
>
<p className="text-[12px]">暂无任务</p>
<p className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
点击下方按钮添加
</p>
</div>
)}
</div>
{/* Footer: Add task — refined style */}
<div
className="px-2 py-1.5 border-t"
style={{ borderColor: 'var(--border-color-light)' }}
>
<button
className="flex items-center justify-center gap-1.5 w-full py-[6px] text-[12px] rounded-md border border-dashed transition-all hover:border-solid hover:bg-[var(--panel-bg-secondary)]"
style={{
borderColor: 'var(--border-color-light)',
color: 'var(--text-tertiary)',
}}
>
<Plus className="w-3 h-3" />
添加任务
</button>
</div>
</div>
</div>
);
}
-88
View File
@@ -1,88 +0,0 @@
import { Pencil, Trash2 } from 'lucide-react';
import { iconMap } from '@/data/pipelineData';
import type { PipelineTask } from '@/types/pipeline';
interface TaskCardProps {
task: PipelineTask;
isSelected: boolean;
onSelect: () => void;
onDelete: () => void;
}
// Subtle type indicator colors — just for the small dot/icon, NOT background
const typeConfig: Record<string, { color: string; label: string }> = {
build: { color: '#9a6700', label: '构建' },
deploy: { color: '#1a7f37', label: '部署' },
test: { color: '#8250df', label: '测试' },
custom: { color: '#6e7781', label: '自定义' },
};
export default function TaskCard({ task, isSelected, onSelect, onDelete }: TaskCardProps) {
const config = typeConfig[task.type] || typeConfig.custom;
const IconComponent = iconMap[task.icon] || iconMap.Settings;
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
onDelete();
};
return (
<div
onClick={onSelect}
className="flex items-center gap-2.5 px-3 py-[10px] rounded-md cursor-pointer transition-all duration-150 group mb-[2px] last:mb-0"
style={{
background: isSelected ? '#f0f5ff' : 'transparent',
border: isSelected ? '1px solid #b7d4f0' : '1px solid transparent',
...(isSelected
? { boxShadow: '0 0 0 1px rgba(9,105,218,0.08)' }
: {}),
}}
>
{/* Type icon — small colored dot with icon inside */}
<div
className="w-6 h-6 rounded-[6px] flex items-center justify-center flex-shrink-0"
style={{
background: `${config.color}10`,
border: `1px solid ${config.color}25`,
}}
>
<IconComponent className="w-3 h-3" style={{ color: config.color }} />
</div>
{/* Task info */}
<div className="flex-1 min-w-0">
<div
className="text-[13px] font-medium truncate leading-tight"
style={{ color: isSelected ? 'var(--text-primary)' : 'var(--text-primary)' }}
>
{task.name}
</div>
<div
className="text-[11px] truncate leading-tight mt-[1px]"
style={{ color: 'var(--text-tertiary)' }}
>
{config.label}
{task.scriptType && ` · ${task.scriptType}`}
</div>
</div>
{/* Actions — hover reveal */}
<div className="flex items-center gap-[2px] opacity-0 group-hover:opacity-100 transition-opacity duration-150 flex-shrink-0">
<button
onClick={(e) => { e.stopPropagation(); onSelect(); }}
className="p-1 rounded transition-colors hover:bg-black/5"
title="编辑"
>
<Pencil className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
</button>
<button
onClick={handleDelete}
className="p-1 rounded transition-colors hover:bg-red-50"
title="删除"
>
<Trash2 className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
</button>
</div>
</div>
);
}
+41 -43
View File
@@ -1,45 +1,52 @@
import { ChevronLeft, Save, Play, MonitorPlay, ChevronDown } from 'lucide-react';
import { ChevronLeft, Save, Play, ChevronDown, Loader2 } from 'lucide-react';
import { useState } from 'react';
type EditorTab = '基本信息' | '流程配置' | '触发设置' | '变量和缓存' | '发布';
interface PipelineOption {
id: number;
name: string;
}
interface TopBarProps {
pipelineName: string;
pipelines: PipelineOption[];
currentPipelineId: number;
onPipelineChange: (id: number) => void;
activeTab: EditorTab;
onTabChange: (tab: EditorTab) => void;
onSimulateRun: () => void;
isSimulating: boolean;
scenarioKey: 'go-backend' | 'vue-frontend';
onScenarioChange: (key: 'go-backend' | 'vue-frontend') => void;
executionStatus: 'idle' | 'running' | 'success' | 'failed';
onOnlySave: () => void;
onSaveAndRun: () => void;
isSaving?: boolean;
isRunning?: boolean;
executionStatus: 'idle' | 'running' | 'success' | 'failed' | 'cancelled';
onBack?: () => void;
}
const tabs: EditorTab[] = ['基本信息', '流程配置', '触发设置', '变量和缓存', '发布'];
const scenarios = [
{ key: 'go-backend' as const, label: 'aiops-backend (Go + K8s)' },
{ key: 'vue-frontend' as const, label: 'aiops-web (Vue + Host)' },
];
export default function TopBar({
pipelineName,
pipelines,
currentPipelineId,
onPipelineChange,
activeTab,
onTabChange,
onSimulateRun,
isSimulating,
scenarioKey,
onScenarioChange,
onOnlySave,
onSaveAndRun,
isSaving = false,
isRunning = false,
executionStatus,
onBack,
}: TopBarProps) {
const [pipelineDropdownOpen, setPipelineDropdownOpen] = useState(false);
const statusConfig = {
const statusConfig: Record<string, { color: string; bg: string; label: string } | null> = {
idle: null,
running: { color: '#0969da', bg: '#ddf4ff', label: '执行中' },
success: { color: '#1a7f37', bg: '#dafbe1', label: '成功' },
failed: { color: '#cf222e', bg: '#ffebe9', label: '失败' },
cancelled: { color: '#6e7781', bg: '#f6f8fa', label: '已取消' },
};
const status = statusConfig[executionStatus];
@@ -82,24 +89,24 @@ export default function TopBar({
<>
<div className="fixed inset-0 z-40" onClick={() => setPipelineDropdownOpen(false)} />
<div
className="absolute top-full left-0 mt-1 py-1 rounded-md border shadow-lg z-50 min-w-[200px]"
className="absolute top-full left-0 mt-1 py-1 rounded-md border shadow-lg z-50 min-w-[200px] max-h-[320px] overflow-y-auto custom-scrollbar"
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
>
{scenarios.map((s) => (
{pipelines.map((p) => (
<button
key={s.key}
key={p.id}
onClick={() => {
onScenarioChange(s.key);
onPipelineChange(p.id);
setPipelineDropdownOpen(false);
}}
className="w-full text-left px-3 py-2 text-[13px] transition-colors hover:bg-[var(--panel-bg-secondary)]"
style={{
color: scenarioKey === s.key ? 'var(--accent-blue)' : 'var(--text-primary)',
fontWeight: scenarioKey === s.key ? 600 : 400,
color: p.id === currentPipelineId ? 'var(--accent-blue)' : 'var(--text-primary)',
fontWeight: p.id === currentPipelineId ? 600 : 400,
}}
>
{s.label}
{scenarioKey === s.key && (
{p.name}
{p.id === currentPipelineId && (
<span className="ml-2 text-[11px]" style={{ color: 'var(--accent-blue)' }}>✓</span>
)}
</button>
@@ -112,7 +119,7 @@ export default function TopBar({
{/* Execution status badge */}
{status && (
<span
className="text-[10px] px-1.5 py-[2px] rounded-full font-medium"
className={`text-[10px] px-1.5 py-[2px] rounded-full font-medium ${executionStatus === 'running' ? 'animate-pulse' : ''}`}
style={{ background: status.bg, color: status.color }}
>
{status.label}
@@ -142,35 +149,26 @@ export default function TopBar({
{/* Right: Action Buttons */}
<div className="flex items-center gap-2 min-w-[280px] justify-end">
<button
onClick={onSimulateRun}
disabled={isSimulating}
onClick={onOnlySave}
disabled={isSaving}
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md transition-colors disabled:opacity-50"
style={{
color: 'var(--accent-blue)',
background: 'var(--accent-blue-light)',
border: '1px solid rgba(9, 105, 218, 0.2)',
}}
>
<MonitorPlay className="w-4 h-4" />
{isSimulating ? '运行中...' : '模拟运行'}
</button>
<button
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md transition-colors"
style={{
color: 'var(--text-secondary)',
background: 'transparent',
border: '1px solid var(--border-color)',
}}
>
<Save className="w-4 h-4" />
{isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
仅保存
</button>
<button
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md text-white transition-colors"
style={{ background: 'var(--accent-blue)' }}
onClick={onSaveAndRun}
disabled={isRunning}
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md text-white transition-colors disabled:opacity-60"
style={{ background: isRunning ? '#6e7781' : 'var(--accent-blue)' }}
>
<Play className="w-4 h-4" />
保存并运行
{isRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
{isRunning ? '运行中…' : '保存并运行'}
</button>
</div>
</div>
+126 -239
View File
@@ -1,257 +1,144 @@
import { useState } from 'react';
import { GitCommit, Clock, Webhook, Hand, Info, Copy, Check } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useEffect, useState } from 'react';
import { Webhook, Hand, Clock, GitCommit, Save, Loader2, Copy, Check } from 'lucide-react';
import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api';
export default function TriggerPanel() {
// Code commit trigger
const [codeCommitEnabled, setCodeCommitEnabled] = useState(true);
const [watchBranch, setWatchBranch] = useState('');
const [excludeBranch, setExcludeBranch] = useState('');
interface Props {
pipelineId: number;
}
// Cron trigger
const [cronEnabled, setCronEnabled] = useState(false);
const [cronExpression, setCronExpression] = useState('');
const [nextExecution, setNextExecution] = useState('2024-06-21 02:00:00');
const modes = [
{ key: 'manual', label: '手动触发', desc: '在流水线列表或编辑器中手动执行', icon: Hand },
{ key: 'webhook', label: 'Webhook', desc: '通过 Webhook URL 推送事件触发', icon: Webhook },
{ key: 'git_push', label: 'Git Push', desc: '代码仓库分支变更时触发', icon: GitCommit },
{ key: 'cron', label: '定时触发', desc: '按 Cron 表达式定时执行', icon: Clock },
];
// Webhook trigger
const [webhookEnabled, setWebhookEnabled] = useState(false);
const [webhookSecret, setWebhookSecret] = useState('');
export default function TriggerPanel({ pipelineId }: Props) {
const queryClient = useQueryClient();
const { data: detail } = useQuery({
queryKey: ['pipeline', pipelineId],
queryFn: () => apiClient.getPipeline(pipelineId),
});
const p = detail?.pipeline;
const [form, setForm] = useState({ triggerMode: 'manual', cronExpr: '', webhookToken: '', watchBranches: '' });
const [copied, setCopied] = useState(false);
const webhookUrl = 'https://api.example.com/webhook/pipeline/gateway-pre-serverless';
const handleCopyWebhook = () => {
navigator.clipboard.writeText(webhookUrl).then(() => {
setCopied(true);
toast.success('Webhook URL 已复制');
setTimeout(() => setCopied(false), 2000);
});
};
const cronPresets = [
{ label: '每天凌晨2点', value: '0 2 * * *' },
{ label: '每小时', value: '0 * * * *' },
{ label: '自定义', value: '' },
];
const handleCronPreset = (value: string) => {
setCronExpression(value);
if (value === '0 2 * * *') {
setNextExecution('2024-06-21 02:00:00');
} else if (value === '0 * * * *') {
setNextExecution('2024-06-20 15:00:00');
useEffect(() => {
if (p) {
setForm({
triggerMode: p.triggerMode || 'manual',
cronExpr: p.cronExpr || '',
webhookToken: p.webhookToken || '',
watchBranches: p.watchBranches || '',
});
}
}, [p?.id, p?.updatedAt]);
const saveMutation = useMutation({
mutationFn: (body: any) => apiClient.updatePipeline(pipelineId, body),
onSuccess: () => {
toast.success('触发设置已保存');
queryClient.invalidateQueries({ queryKey: ['pipeline', pipelineId] });
},
onError: (err: Error) => toast.error('保存失败', { description: err.message }),
});
const regenToken = () => {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
const token = Array.from(array, (b) => b.toString(16).padStart(2, '0')).join('');
setForm((f) => ({ ...f, webhookToken: token }));
};
const hookUrl = `${window.location.origin}/api/hooks/pipeline/${pipelineId}${form.webhookToken ? `?token=${form.webhookToken}` : ''}`;
const copyUrl = () => {
navigator.clipboard.writeText(hookUrl);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
const inputStyle = { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' };
return (
<div className="tab-content-fade space-y-4 w-full px-6 py-6">
{/* Code Commit Trigger */}
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GitCommit className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
<CardTitle className="text-base">代码提交触发</CardTitle>
</div>
<Switch checked={codeCommitEnabled} onCheckedChange={setCodeCommitEnabled} />
</div>
<CardDescription>
当指定分支有新的代码提交时自动触发流水线
</CardDescription>
</CardHeader>
{codeCommitEnabled && (
<CardContent className="tab-content-fade space-y-4">
<div>
<div className="flex items-center gap-2 mb-1.5">
<Label className="text-sm" style={{ color: 'var(--text-secondary)' }}>监听分支</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
</TooltipTrigger>
<TooltipContent>
<p>支持正则表达式,如 release/*</p>
</TooltipContent>
</Tooltip>
</div>
<Input
value={watchBranch}
onChange={(e) => setWatchBranch(e.target.value)}
placeholder="支持正则,如 release/*"
className="h-9"
/>
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
多个分支用逗号分隔,支持 * 通配符
</p>
</div>
<div>
<Label className="text-sm mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
排除分支
</Label>
<Input
value={excludeBranch}
onChange={(e) => setExcludeBranch(e.target.value)}
placeholder="如 hotfix/*"
className="h-9"
/>
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
匹配的分支不会触发流水线
</p>
</div>
</CardContent>
)}
</Card>
<div className="tab-content-fade w-full px-6 py-6 max-w-3xl overflow-y-auto">
<div className="rounded-lg border p-6 shadow-sm" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-center justify-between mb-5">
<h3 className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>触发方式</h3>
<button
onClick={() => saveMutation.mutate(form)}
disabled={saveMutation.isPending}
className="flex items-center gap-1 px-3 py-1 text-[12px] rounded-md text-white disabled:opacity-50"
style={{ background: 'var(--accent-blue)' }}
>
{saveMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}保存
</button>
</div>
{/* Cron Trigger */}
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Clock className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
<CardTitle className="text-base">定时触发</CardTitle>
</div>
<Switch checked={cronEnabled} onCheckedChange={setCronEnabled} />
</div>
<CardDescription>
按照 Cron 表达式定时触发流水线
</CardDescription>
</CardHeader>
{cronEnabled && (
<CardContent className="tab-content-fade space-y-4">
<div className="flex flex-wrap gap-2">
{cronPresets.map((preset) => (
<Badge
key={preset.label}
variant={cronExpression === preset.value && preset.value !== '' ? 'default' : 'outline'}
className="cursor-pointer h-7 px-3"
onClick={() => handleCronPreset(preset.value)}
>
{preset.label}
</Badge>
))}
</div>
<div>
<Label className="text-sm mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
Cron 表达式
</Label>
<Input
value={cronExpression}
onChange={(e) => setCronExpression(e.target.value)}
placeholder="0 2 * * *"
className="h-9 font-mono"
/>
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
格式:分 时 日 月 周
</p>
</div>
{cronExpression && (
<div
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm"
style={{ background: 'var(--canvas-bg)', color: 'var(--text-primary)' }}
<div className="grid grid-cols-2 gap-3">
{modes.map((m) => {
const Icon = m.icon;
const active = form.triggerMode === m.key;
return (
<button
key={m.key}
onClick={() => setForm((f) => ({ ...f, triggerMode: m.key }))}
className="flex items-start gap-3 p-4 rounded-lg border text-left transition-colors"
style={{
borderColor: active ? 'var(--accent-blue)' : 'var(--border-color-light)',
background: active ? 'rgba(9,105,218,0.04)' : 'transparent',
}}
>
<Clock className="w-4 h-4" style={{ color: 'var(--accent-blue)' }} />
下次执行: {nextExecution}
<Icon className="w-4.5 h-4.5 mt-0.5" style={{ color: active ? 'var(--accent-blue)' : 'var(--text-tertiary)' }} />
<div>
<div className="text-[13px] font-medium" style={{ color: active ? 'var(--accent-blue)' : 'var(--text-primary)' }}>{m.label}</div>
<div className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{m.desc}</div>
</div>
</button>
);
})}
</div>
{form.triggerMode === 'cron' && (
<div className="mt-4 space-y-1.5">
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Cron 表达式</label>
<input value={form.cronExpr} onChange={(e) => setForm((f) => ({ ...f, cronExpr: e.target.value }))} placeholder="0 2 * * *(每天凌晨 2 点)" className="w-full px-3 py-2 text-[13px] rounded-md border outline-none font-mono" style={inputStyle} />
</div>
)}
{(form.triggerMode === 'webhook' || form.triggerMode === 'git_push') && (
<div className="mt-4 space-y-4">
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Webhook Token(触发校验)</label>
<button onClick={regenToken} className="text-[11px] px-2 py-[2px] rounded" style={{ color: 'var(--accent-blue)', background: 'rgba(9,105,218,0.08)' }}>随机生成</button>
</div>
<input value={form.webhookToken} onChange={(e) => setForm((f) => ({ ...f, webhookToken: e.target.value }))} className="w-full px-3 py-2 text-[13px] rounded-md border outline-none font-mono" style={inputStyle} />
</div>
<div className="space-y-1.5">
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Webhook URL(保存后生效)</label>
<div className="flex items-center gap-2">
<input readOnly value={hookUrl} className="flex-1 px-3 py-2 text-[12px] rounded-md border outline-none font-mono" style={inputStyle} />
<button onClick={copyUrl} className="p-2 rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }} title="复制">
{copied ? <Check className="w-3.5 h-3.5" style={{ color: '#1a7f37' }} /> : <Copy className="w-3.5 h-3.5" />}
</button>
</div>
<div className="text-[11px] font-mono p-2.5 rounded-md" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
curl -X POST '{hookUrl}'
</div>
</div>
{form.triggerMode === 'git_push' && (
<div className="space-y-1.5">
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>监听分支(逗号分隔,支持通配符)</label>
<input value={form.watchBranches} onChange={(e) => setForm((f) => ({ ...f, watchBranches: e.target.value }))} placeholder="master,release/*,feature/*" className="w-full px-3 py-2 text-[13px] rounded-md border outline-none font-mono" style={inputStyle} />
</div>
)}
</CardContent>
</div>
)}
</Card>
{/* Webhook Trigger */}
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Webhook className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
<CardTitle className="text-base">Webhook 触发</CardTitle>
</div>
<Switch checked={webhookEnabled} onCheckedChange={setWebhookEnabled} />
</div>
<CardDescription>
通过外部系统的 Webhook 调用触发流水线
</CardDescription>
</CardHeader>
{webhookEnabled && (
<CardContent className="tab-content-fade space-y-4">
<div>
<Label className="text-sm mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
Webhook URL
</Label>
<div className="flex gap-2">
<div
className="flex-1 px-3 py-2 text-sm rounded-md border font-mono"
style={{
background: 'var(--canvas-bg)',
borderColor: 'var(--border-color)',
color: 'var(--text-primary)',
}}
>
{webhookUrl}
</div>
<Button variant="outline" size="icon" className="h-9 w-9" onClick={handleCopyWebhook}>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</Button>
</div>
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
将此 URL 配置到外部系统的 Webhook 中
</p>
</div>
<div>
<div className="flex items-center gap-2 mb-1.5">
<Label className="text-sm" style={{ color: 'var(--text-secondary)' }}>Secret Token</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
</TooltipTrigger>
<TooltipContent>
<p>用于验证 Webhook 请求的签名</p>
</TooltipContent>
</Tooltip>
</div>
<Input
value={webhookSecret}
onChange={(e) => setWebhookSecret(e.target.value)}
placeholder="输入 Secret Token"
type="password"
className="h-9"
/>
</div>
</CardContent>
)}
</Card>
{/* Manual Trigger - always on */}
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Hand className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
<CardTitle className="text-base">手动触发</CardTitle>
<Badge variant="secondary" className="h-5 text-xs">默认启用</Badge>
</div>
<CardDescription>
支持通过控制台手动触发或 API 调用触发
</CardDescription>
</CardHeader>
<CardContent>
<div
className="px-3 py-2.5 rounded-md text-sm"
style={{ background: 'var(--canvas-bg)', color: 'var(--text-primary)' }}
>
<span style={{ color: 'var(--text-secondary)' }}>
API 调用示例:
</span>
<code className="ml-2 text-xs font-mono" style={{ color: 'var(--accent-blue)' }}>
POST /api/v1/pipelines/gateway-pre-serverless/run
</code>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
+96 -243
View File
@@ -1,261 +1,114 @@
import { useState } from 'react';
import { Variable, Database, Trash2, Plus, Info, Eye, EyeOff } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useEffect, useState } from 'react';
import { Variable, Database, Trash2, Plus, Save, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api';
interface EnvVar {
id: string;
name: string;
value: string;
description: string;
interface Props {
pipelineId: number;
}
export default function VariablesPanel() {
const [envVars, setEnvVars] = useState<EnvVar[]>([
{ id: '1', name: 'REGISTRY_URL', value: 'registry.cn-hangzhou.aliyuncs.com', description: '镜像仓库地址' },
{ id: '2', name: 'NAMESPACE', value: 'gateway-ns', description: 'K8s 命名空间' },
{ id: '3', name: 'APP_NAME', value: 'gateway-serverless', description: '应用名称' },
{ id: '4', name: 'ENV', value: 'pre', description: '环境标识' },
]);
interface VarItem { key: string; value: string; remark?: string }
interface CacheItem { path: string; key: string }
const [showAddForm, setShowAddForm] = useState(false);
const [newVarName, setNewVarName] = useState('');
const [newVarValue, setNewVarValue] = useState('');
const [newVarDesc, setNewVarDesc] = useState('');
const [visibleValues, setVisibleValues] = useState<Record<string, boolean>>({});
function parseJSON<T>(s: string | undefined, fallback: T): T {
if (!s) return fallback;
try {
const v = JSON.parse(s);
return Array.isArray(v) ? (v as T) : fallback;
} catch {
return fallback;
}
}
// Cache config
const [cacheEnabled, setCacheEnabled] = useState(true);
const [cachePath, setCachePath] = useState('/root/.m2');
const [cacheKey, setCacheKey] = useState('${BRANCH_NAME}-${BUILD_ID}');
export default function VariablesPanel({ pipelineId }: Props) {
const queryClient = useQueryClient();
const { data: detail } = useQuery({
queryKey: ['pipeline', pipelineId],
queryFn: () => apiClient.getPipeline(pipelineId),
});
const p = detail?.pipeline;
const handleAddVar = () => {
if (!newVarName.trim() || !newVarValue.trim()) return;
const newVar: EnvVar = {
id: Date.now().toString(),
name: newVarName.trim(),
value: newVarValue.trim(),
description: newVarDesc.trim(),
};
setEnvVars([...envVars, newVar]);
setNewVarName('');
setNewVarValue('');
setNewVarDesc('');
setShowAddForm(false);
};
const [vars, setVars] = useState<VarItem[]>([]);
const [caches, setCaches] = useState<CacheItem[]>([]);
const handleDeleteVar = (id: string) => {
setEnvVars(envVars.filter((v) => v.id !== id));
};
useEffect(() => {
if (p) {
setVars(parseJSON<VarItem[]>(p.variables, []));
setCaches(parseJSON<CacheItem[]>(p.cachePaths, []));
}
}, [p?.id, p?.updatedAt]);
const toggleVisible = (id: string) => {
setVisibleValues((prev) => ({ ...prev, [id]: !prev[id] }));
};
const saveMutation = useMutation({
mutationFn: () => apiClient.updatePipeline(pipelineId, {
variables: JSON.stringify(vars.filter((v) => v.key)),
cachePaths: JSON.stringify(caches.filter((c) => c.path)),
}),
onSuccess: () => {
toast.success('变量与缓存配置已保存');
queryClient.invalidateQueries({ queryKey: ['pipeline', pipelineId] });
},
onError: (err: Error) => toast.error('保存失败', { description: err.message }),
});
const inputStyle = { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' };
return (
<div className="tab-content-fade space-y-4 w-full px-6 py-6">
{/* Environment Variables */}
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}>
<CardHeader className="pb-3">
<div className="tab-content-fade w-full px-6 py-6 max-w-4xl">
<div className="rounded-lg border p-6 shadow-sm" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Variable className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
<CardTitle className="text-base">环境变量</CardTitle>
</div>
<CardDescription className="flex items-center gap-1">
在流水线各阶段可通过
<code className="px-1 py-0.5 rounded text-xs font-mono" style={{ background: 'var(--canvas-bg)' }}>
${'{VAR_NAME}'}
</code>
引用
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-color)' }}>
<Table>
<TableHeader>
<TableRow style={{ background: 'var(--canvas-bg)' }}>
<TableHead className="text-xs font-semibold h-9">变量名</TableHead>
<TableHead className="text-xs font-semibold h-9">变量值</TableHead>
<TableHead className="text-xs font-semibold h-9">说明</TableHead>
<TableHead className="text-xs font-semibold h-9 w-16">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{envVars.map((envVar) => (
<TableRow key={envVar.id} className="group">
<TableCell className="font-mono text-sm py-2.5">{envVar.name}</TableCell>
<TableCell className="py-2.5">
<div className="flex items-center gap-2">
<span className="font-mono text-sm">
{visibleValues[envVar.id] ? envVar.value : '••••••••'}
</span>
<button
onClick={() => toggleVisible(envVar.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity"
>
{visibleValues[envVar.id] ? (
<EyeOff className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
) : (
<Eye className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
)}
</button>
</div>
</TableCell>
<TableCell className="text-sm py-2.5" style={{ color: 'var(--text-secondary)' }}>
{envVar.description}
</TableCell>
<TableCell className="py-2.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => handleDeleteVar(envVar.id)}
>
<Trash2 className="w-3.5 h-3.5" style={{ color: '#EF4444' }} />
</Button>
</TableCell>
</TableRow>
))}
{envVars.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center py-6 text-sm" style={{ color: 'var(--text-secondary)' }}>
暂无环境变量
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<h3 className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>环境变量</h3>
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{vars.length} 项</span>
</div>
<button onClick={() => setVars((v) => [...v, { key: '', value: '', remark: '' }])} className="flex items-center gap-1 px-2 py-1 text-[11px] rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<Plus className="w-3 h-3" />添加变量
</button>
</div>
{showAddForm ? (
<div className="tab-content-fade space-y-3 p-4 rounded-lg border" style={{ borderColor: 'var(--border-color)', background: 'var(--canvas-bg)' }}>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>变量名</Label>
<Input
value={newVarName}
onChange={(e) => setNewVarName(e.target.value)}
placeholder="如 BUILD_ARGS"
className="h-8 font-mono"
/>
</div>
<div>
<Label className="text-xs mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>变量值</Label>
<Input
value={newVarValue}
onChange={(e) => setNewVarValue(e.target.value)}
placeholder="变量值"
className="h-8"
/>
</div>
</div>
<div>
<Label className="text-xs mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>说明</Label>
<Input
value={newVarDesc}
onChange={(e) => setNewVarDesc(e.target.value)}
placeholder="变量说明(可选)"
className="h-8"
/>
</div>
<div className="flex gap-2 pt-1">
<Button size="sm" className="h-8" onClick={handleAddVar}>
确认添加
</Button>
<Button variant="outline" size="sm" className="h-8" onClick={() => setShowAddForm(false)}>
取消
</Button>
</div>
</div>
) : (
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => setShowAddForm(true)}
>
<Plus className="w-3.5 h-3.5 mr-1.5" />
添加变量
</Button>
)}
</CardContent>
</Card>
{/* Cache Config */}
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Database className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
<CardTitle className="text-base">缓存配置</CardTitle>
</div>
<Switch checked={cacheEnabled} onCheckedChange={setCacheEnabled} />
{vars.length === 0 && <div className="text-[12px] py-4 text-center" style={{ color: 'var(--text-tertiary)' }}>暂无环境变量</div>}
{vars.map((v, i) => (
<div key={i} className="flex items-center gap-2 mb-2">
<input value={v.key} onChange={(e) => setVars((arr) => arr.map((x, j) => (j === i ? { ...x, key: e.target.value } : x)))} placeholder="KEY" className="w-40 px-2.5 py-1.5 text-[12px] rounded-md border outline-none font-mono" style={inputStyle} />
<input value={v.value} onChange={(e) => setVars((arr) => arr.map((x, j) => (j === i ? { ...x, value: e.target.value } : x)))} placeholder="value" className="flex-1 px-2.5 py-1.5 text-[12px] rounded-md border outline-none font-mono" style={inputStyle} />
<input value={v.remark || ''} onChange={(e) => setVars((arr) => arr.map((x, j) => (j === i ? { ...x, remark: e.target.value } : x)))} placeholder="备注" className="w-32 px-2.5 py-1.5 text-[12px] rounded-md border outline-none" style={inputStyle} />
<button onClick={() => setVars((arr) => arr.filter((_, j) => j !== i))} className="p-1.5 rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: '#cf222e' }} title="删除">
<Trash2 className="w-3 h-3" />
</button>
</div>
<CardDescription>
配置构建缓存可显著加速后续构建速度
</CardDescription>
</CardHeader>
{cacheEnabled && (
<CardContent className="tab-content-fade space-y-4">
<div>
<div className="flex items-center gap-2 mb-1.5">
<Label className="text-sm" style={{ color: 'var(--text-secondary)' }}>缓存路径</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
</TooltipTrigger>
<TooltipContent>
<p>需要缓存的目录路径,多个路径用逗号分隔</p>
</TooltipContent>
</Tooltip>
</div>
<Input
value={cachePath}
onChange={(e) => setCachePath(e.target.value)}
placeholder="/root/.m2, node_modules"
className="h-9"
/>
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
多个路径用逗号分隔
</p>
</div>
<div>
<div className="flex items-center gap-2 mb-1.5">
<Label className="text-sm" style={{ color: 'var(--text-secondary)' }}>缓存 Key</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
</TooltipTrigger>
<TooltipContent>
<p>支持使用变量,用于区分不同场景的缓存</p>
</TooltipContent>
</Tooltip>
</div>
<Input
value={cacheKey}
onChange={(e) => setCacheKey(e.target.value)}
placeholder="${BRANCH_NAME}-${BUILD_ID}"
className="h-9 font-mono"
/>
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
支持变量引用,相同的 Key 会命中已有缓存
</p>
</div>
</CardContent>
)}
</Card>
))}
</div>
<div className="rounded-lg border p-6 shadow-sm mt-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Database className="w-5 h-5" style={{ color: '#1a7f37' }} />
<h3 className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>缓存配置</h3>
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{caches.length} 项</span>
</div>
<button onClick={() => setCaches((c) => [...c, { path: '', key: '' }])} className="flex items-center gap-1 px-2 py-1 text-[11px] rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<Plus className="w-3 h-3" />添加缓存
</button>
</div>
{caches.length === 0 && <div className="text-[12px] py-4 text-center" style={{ color: 'var(--text-tertiary)' }}>暂无缓存配置</div>}
{caches.map((c, i) => (
<div key={i} className="flex items-center gap-2 mb-2">
<input value={c.path} onChange={(e) => setCaches((arr) => arr.map((x, j) => (j === i ? { ...x, path: e.target.value } : x)))} placeholder="缓存路径(如 /root/.m2)" className="flex-1 px-2.5 py-1.5 text-[12px] rounded-md border outline-none font-mono" style={inputStyle} />
<input value={c.key} onChange={(e) => setCaches((arr) => arr.map((x, j) => (j === i ? { ...x, key: e.target.value } : x)))} placeholder="缓存 Key(如 ${BRANCH}-${BUILD_ID})" className="flex-1 px-2.5 py-1.5 text-[12px] rounded-md border outline-none font-mono" style={inputStyle} />
<button onClick={() => setCaches((arr) => arr.filter((_, j) => j !== i))} className="p-1.5 rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: '#cf222e' }} title="删除">
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
</div>
<div className="mt-4 flex justify-end">
<button onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending} className="flex items-center gap-1 px-4 py-1.5 text-[12px] rounded-md text-white disabled:opacity-50" style={{ background: 'var(--accent-blue)' }}>
{saveMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}保存全部
</button>
</div>
</div>
);
}
@@ -26,6 +26,10 @@ import { apiClient } from '@/lib/api';
interface FlowEditorProps {
pipelineId: number;
/** 自增信号:TopBar「仅保存」触发 */
saveSignal?: number;
/** 自增信号:TopBar「保存并运行」触发 */
runSignal?: number;
}
interface CatalogTask {
@@ -58,7 +62,7 @@ const categoryColors: Record<string, string> = {
const nodeTypes = { tektonNode: TektonNode };
function FlowEditorInner({ pipelineId }: FlowEditorProps) {
function FlowEditorInner({ pipelineId, saveSignal = 0, runSignal = 0 }: FlowEditorProps) {
const [nodes, setNodes, onNodesChange] = useNodesState<Node<TektonNodeData>>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const [catalogOpen, setCatalogOpen] = useState(true);
@@ -212,6 +216,22 @@ function FlowEditorInner({ pipelineId }: FlowEditorProps) {
}
}, [logs, logOpen]);
// TopBar「仅保存」信号(用 ref 取最新 handler 避免闭包过期)
const saveRef = useRef(handleSave);
saveRef.current = handleSave;
const runRef = useRef(handleRun);
runRef.current = handleRun;
useEffect(() => {
if (saveSignal > 0) saveRef.current();
}, [saveSignal]);
useEffect(() => {
if (runSignal > 0) {
saveRef.current().then(() => runRef.current()).catch(() => runRef.current());
}
}, [runSignal]);
const onConnect = useCallback(
(params: Connection) => setEdges((eds) => addEdge({ ...params, animated: true, style: { stroke: '#0969da' } }, eds)),
[setEdges]