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:
1 parent
04d1342b27
commit
26341c3e8a
37 files changed
+5702
-4683
No files matched your search
+396
-147
@@ -1,10 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Play,
|
||||
Settings,
|
||||
Trash2,
|
||||
import { useState, type CSSProperties } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Play,
|
||||
Settings,
|
||||
Trash2,
|
||||
GitBranch,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
@@ -13,97 +13,172 @@ import {
|
||||
Filter,
|
||||
LayoutGrid,
|
||||
List,
|
||||
MoreHorizontal
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
interface Pipeline {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
lastStatus: 'success' | 'failed' | 'running' | 'pending';
|
||||
lastRunAt: string;
|
||||
triggerType: string;
|
||||
duration: string;
|
||||
branch: string;
|
||||
deployType: 'host' | 'k8s';
|
||||
// ---- 工具函数 ----
|
||||
function fmtDuration(ms?: number | null): string {
|
||||
if (ms == null || ms < 0) return '-';
|
||||
const sec = ms / 1000;
|
||||
if (ms < 1000) return `${Math.max(0, Math.round(sec))}s`;
|
||||
if (sec < 60) return `${sec.toFixed(1)}s`;
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}分${Math.round(sec % 60)}秒`;
|
||||
return `${Math.floor(sec / 3600)}小时${Math.round((sec % 3600) / 60)}分`;
|
||||
}
|
||||
|
||||
const mockPipelines: Pipeline[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'aiops-backend',
|
||||
description: 'Go后端服务 - Docker构建 + K8s部署',
|
||||
lastStatus: 'success',
|
||||
lastRunAt: '2024-06-20 14:30:15',
|
||||
triggerType: 'Git Push',
|
||||
duration: '3分22秒',
|
||||
branch: 'master',
|
||||
deployType: 'k8s',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'aiops-web',
|
||||
description: 'Vue前端应用 - npm构建 + 主机部署',
|
||||
lastStatus: 'running',
|
||||
lastRunAt: '2024-06-20 15:45:00',
|
||||
triggerType: '手动',
|
||||
duration: '1分12秒',
|
||||
branch: 'master',
|
||||
deployType: 'host',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'gateway-api',
|
||||
description: 'API网关服务 - Java构建 + K8s部署',
|
||||
lastStatus: 'failed',
|
||||
lastRunAt: '2024-06-20 13:20:00',
|
||||
triggerType: '手动',
|
||||
duration: '45秒',
|
||||
branch: 'release/v2.1',
|
||||
deployType: 'k8s',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'admin-dashboard',
|
||||
description: '管理后台 - React构建 + 主机部署',
|
||||
lastStatus: 'success',
|
||||
lastRunAt: '2024-06-20 12:10:00',
|
||||
triggerType: 'Cron',
|
||||
duration: '2分05秒',
|
||||
branch: 'master',
|
||||
deployType: 'host',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'data-processor',
|
||||
description: '数据处理服务 - Python构建 + K8s部署',
|
||||
lastStatus: 'pending',
|
||||
lastRunAt: '-',
|
||||
triggerType: 'Webhook',
|
||||
duration: '-',
|
||||
branch: 'develop',
|
||||
deployType: 'k8s',
|
||||
},
|
||||
];
|
||||
function fmtTime(iso?: string | null): string {
|
||||
return iso ? new Date(iso).toLocaleString('zh-CN', { hour12: false }) : '-';
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
success: { icon: CheckCircle2, color: '#1a7f37', bg: '#dafbe1', label: '成功' },
|
||||
failed: { icon: XCircle, color: '#cf222e', bg: '#ffebe9', label: '失败' },
|
||||
running: { icon: Loader2, color: '#0969da', bg: '#ddf4ff', label: '执行中' },
|
||||
pending: { icon: Clock, color: '#6e7781', bg: '#f6f8fa', label: '未执行' },
|
||||
function timeAgo(iso?: string | null): string {
|
||||
if (!iso) return '-';
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
if (diff < 0) return '刚刚';
|
||||
const s = Math.floor(diff / 1000);
|
||||
if (s < 60) return `${s}秒前`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}分钟前`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}小时前`;
|
||||
return `${Math.floor(h / 24)}天前`;
|
||||
}
|
||||
|
||||
// ---- 类型(与后端 GET /api/pipelines 对齐)----
|
||||
interface LastRun {
|
||||
status: string;
|
||||
startedAt: string;
|
||||
durationMs: number;
|
||||
triggerType: string;
|
||||
}
|
||||
|
||||
interface PipelineDTO {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
type: string; // native | jenkins | tekton
|
||||
sourceBranch?: string;
|
||||
deployType: string; // host | k8s | gitops
|
||||
env?: string;
|
||||
lastRun: LastRun | null;
|
||||
}
|
||||
|
||||
const statusConfig: Record<string, { icon: typeof CheckCircle2; color: string; label: string }> = {
|
||||
success: { icon: CheckCircle2, color: '#1a7f37', label: '成功' },
|
||||
failed: { icon: XCircle, color: '#cf222e', label: '失败' },
|
||||
running: { icon: Loader2, color: '#0969da', label: '执行中' },
|
||||
pending: { icon: Clock, color: '#9a6700', label: '排队中' },
|
||||
queued: { icon: Clock, color: '#9a6700', label: '排队中' },
|
||||
cancelled: { icon: XCircle, color: '#6e7781', label: '已取消' },
|
||||
none: { icon: Clock, color: '#6e7781', label: '未执行' },
|
||||
};
|
||||
|
||||
const deployTypeConfig: Record<string, { label: string; color: string; bg: string }> = {
|
||||
k8s: { label: 'K8s', color: '#0969da', bg: '#ddf4ff' },
|
||||
host: { label: '主机', color: '#1a7f37', bg: '#dafbe1' },
|
||||
gitops: { label: 'GitOps', color: '#0e7c7b', bg: '#d5f5f6' },
|
||||
};
|
||||
const fallbackDeploy = { label: '未知', color: '#6e7781', bg: '#f6f8fa' };
|
||||
|
||||
const triggerTypeLabels: Record<string, string> = {
|
||||
manual: '手动',
|
||||
webhook: 'Webhook',
|
||||
cron: '定时',
|
||||
batch: '批量',
|
||||
};
|
||||
|
||||
interface CreatePipelineForm {
|
||||
name: string;
|
||||
description: string;
|
||||
sourceRepo: string;
|
||||
sourceBranch: string;
|
||||
deployType: string;
|
||||
env: string;
|
||||
}
|
||||
|
||||
const emptyForm: CreatePipelineForm = {
|
||||
name: '',
|
||||
description: '',
|
||||
sourceRepo: '',
|
||||
sourceBranch: 'main',
|
||||
deployType: 'host',
|
||||
env: 'staging',
|
||||
};
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
background: 'var(--panel-bg-secondary)',
|
||||
borderColor: 'var(--border-color-light)',
|
||||
color: 'var(--text-primary)',
|
||||
};
|
||||
|
||||
export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (id: string) => void }) {
|
||||
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [pipelines] = useState<Pipeline[]>(mockPipelines);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form, setForm] = useState<CreatePipelineForm>(emptyForm);
|
||||
|
||||
const filtered = pipelines.filter((p) => {
|
||||
const matchSearch = p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
p.description.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchStatus = statusFilter === 'all' || p.lastStatus === statusFilter;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: pipelines = [], isLoading } = useQuery({
|
||||
queryKey: ['pipelines'],
|
||||
queryFn: () => apiClient.listPipelines(),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: CreatePipelineForm) => apiClient.createPipeline(body),
|
||||
onSuccess: (_data, body) => {
|
||||
toast.success('流水线已创建', { description: body.name });
|
||||
queryClient.invalidateQueries({ queryKey: ['pipelines'] });
|
||||
setCreateOpen(false);
|
||||
setForm(emptyForm);
|
||||
},
|
||||
onError: (e: Error) => toast.error('创建失败', { description: e.message }),
|
||||
});
|
||||
|
||||
const runMutation = useMutation({
|
||||
mutationFn: (id: number | string) => apiClient.createRun(id),
|
||||
onSuccess: (run) => toast.success(`已触发运行 ${run?.runId ?? ''}`),
|
||||
onError: (e: Error) => toast.error('触发失败', { description: e.message }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number | string) => apiClient.deletePipeline(id),
|
||||
onSuccess: () => {
|
||||
toast.success('流水线已删除');
|
||||
queryClient.invalidateQueries({ queryKey: ['pipelines'] });
|
||||
},
|
||||
onError: (e: Error) => toast.error('删除失败', { description: e.message }),
|
||||
});
|
||||
|
||||
const items = (pipelines as PipelineDTO[]).map((p) => {
|
||||
const displayStatus = p.lastRun ? p.lastRun.status : 'none';
|
||||
return {
|
||||
raw: p,
|
||||
id: String(p.id),
|
||||
name: p.name,
|
||||
description: p.description || '',
|
||||
branch: p.sourceBranch || '-',
|
||||
type: p.type,
|
||||
deploy: deployTypeConfig[p.deployType] || fallbackDeploy,
|
||||
// 筛选用映射状态:lastRun 为空记为 pending(未执行)
|
||||
mappedStatus: p.lastRun?.status ?? 'pending',
|
||||
status: statusConfig[displayStatus] || statusConfig.none,
|
||||
isRunning: displayStatus === 'running',
|
||||
durationText: p.lastRun ? fmtDuration(p.lastRun.durationMs) : '-',
|
||||
timeText: p.lastRun ? fmtTime(p.lastRun.startedAt) : '-',
|
||||
timeAgoText: p.lastRun ? timeAgo(p.lastRun.startedAt) : '',
|
||||
triggerText: p.lastRun ? (triggerTypeLabels[p.lastRun.triggerType] || p.lastRun.triggerType) : '-',
|
||||
};
|
||||
});
|
||||
|
||||
const filtered = items.filter((p) => {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const matchSearch = p.name.toLowerCase().includes(q) || p.description.toLowerCase().includes(q);
|
||||
const matchStatus = statusFilter === 'all' || p.mappedStatus === statusFilter;
|
||||
return matchSearch && matchStatus;
|
||||
});
|
||||
|
||||
@@ -115,10 +190,24 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
{ value: 'pending', label: '未执行' },
|
||||
];
|
||||
|
||||
const handleDelete = (p: (typeof items)[number]) => {
|
||||
if (window.confirm(`确定删除流水线「${p.name}」?此操作不可恢复。`)) {
|
||||
deleteMutation.mutate(p.raw.id);
|
||||
}
|
||||
};
|
||||
|
||||
const submitCreate = () => {
|
||||
if (!form.name.trim()) {
|
||||
toast.error('请输入流水线名称');
|
||||
return;
|
||||
}
|
||||
createMutation.mutate({ ...form, name: form.name.trim() });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
||||
{/* Header toolbar */}
|
||||
<div
|
||||
<div
|
||||
className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0"
|
||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||
>
|
||||
@@ -126,18 +215,18 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
流水线列表
|
||||
</h1>
|
||||
<span
|
||||
<span
|
||||
className="text-[11px] px-2 py-[2px] rounded-full font-medium"
|
||||
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
{filtered.length}
|
||||
{items.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Search */}
|
||||
<div
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-[6px] rounded-md border text-[13px]"
|
||||
style={{
|
||||
style={{
|
||||
background: 'var(--panel-bg-secondary)',
|
||||
borderColor: 'var(--border-color-light)',
|
||||
color: 'var(--text-secondary)',
|
||||
@@ -154,9 +243,9 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
</div>
|
||||
{/* Status filter */}
|
||||
<div className="relative group">
|
||||
<button
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3 py-[6px] rounded-md border text-[13px] font-medium"
|
||||
style={{
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: 'var(--border-color-light)',
|
||||
color: 'var(--text-secondary)',
|
||||
@@ -166,7 +255,7 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
{statusOptions.find(s => s.value === statusFilter)?.label}
|
||||
</button>
|
||||
<div className="absolute right-0 top-full mt-1 hidden group-hover:block z-50">
|
||||
<div
|
||||
<div
|
||||
className="rounded-md border shadow-lg py-1 min-w-[120px]"
|
||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||
>
|
||||
@@ -204,7 +293,7 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
onClick={() => toast.info('新建流水线', { description: '功能开发中...' })}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
新建流水线
|
||||
@@ -214,24 +303,29 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
||||
{filtered.length === 0 ? (
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<Loader2 className="w-5 h-5 animate-spin mb-2" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<p className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>加载中...</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<div className="w-12 h-12 rounded-full flex items-center justify-center mb-3" style={{ background: 'var(--panel-bg-secondary)' }}>
|
||||
<Search className="w-5 h-5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</div>
|
||||
<p className="text-[13px] font-medium" style={{ color: 'var(--text-secondary)' }}>未找到流水线</p>
|
||||
<p className="text-[12px] mt-1" style={{ color: 'var(--text-tertiary)' }}>尝试调整搜索条件</p>
|
||||
<p className="text-[12px] mt-1" style={{ color: 'var(--text-tertiary)' }}>尝试调整搜索条件或新建流水线</p>
|
||||
</div>
|
||||
) : viewMode === 'card' ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{filtered.map((pipeline) => {
|
||||
const status = statusConfig[pipeline.lastStatus];
|
||||
const status = pipeline.status;
|
||||
const StatusIcon = status.icon;
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
key={pipeline.id}
|
||||
className="rounded-lg border transition-all duration-200 hover:shadow-md cursor-pointer group"
|
||||
style={{
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: 'var(--border-color)',
|
||||
}}
|
||||
@@ -240,28 +334,35 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
{/* Card header */}
|
||||
<div className="px-4 py-3 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${pipeline.isRunning ? 'animate-pulse' : ''}`}
|
||||
style={{ background: status.color }}
|
||||
/>
|
||||
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{pipeline.name}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-[2px] rounded font-medium uppercase tracking-wide"
|
||||
style={{
|
||||
background: pipeline.deployType === 'k8s' ? '#ddf4ff' : '#dafbe1',
|
||||
color: pipeline.deployType === 'k8s' ? '#0969da' : '#1a7f37',
|
||||
}}
|
||||
>
|
||||
{pipeline.deployType === 'k8s' ? 'K8s' : '主机'}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-[2px] rounded font-medium uppercase tracking-wide"
|
||||
style={{ background: pipeline.deploy.bg, color: pipeline.deploy.color }}
|
||||
>
|
||||
{pipeline.deploy.label}
|
||||
</span>
|
||||
{pipeline.type === 'jenkins' && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-[2px] rounded font-medium"
|
||||
style={{ background: '#fbefff', color: '#8250df' }}
|
||||
>
|
||||
Jenkins
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Card body */}
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-[12px] mb-3 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
{pipeline.description}
|
||||
{pipeline.description || '暂无描述'}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -270,46 +371,59 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{pipeline.duration}
|
||||
{pipeline.durationText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Card footer */}
|
||||
<div
|
||||
<div
|
||||
className="px-4 py-2 border-t flex items-center justify-between"
|
||||
style={{ borderColor: 'var(--border-color-light)' }}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusIcon
|
||||
className={`w-3.5 h-3.5 ${pipeline.lastStatus === 'running' ? 'animate-spin' : ''}`}
|
||||
style={{ color: status.color }}
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<StatusIcon
|
||||
className={`w-3.5 h-3.5 flex-shrink-0 ${pipeline.isRunning ? 'animate-pulse' : ''}`}
|
||||
style={{ color: status.color }}
|
||||
/>
|
||||
<span className="text-[11px] font-medium" style={{ color: status.color }}>
|
||||
{status.label}
|
||||
</span>
|
||||
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{pipeline.lastRunAt}
|
||||
<span
|
||||
className="text-[11px] truncate"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
title={pipeline.timeAgoText || undefined}
|
||||
>
|
||||
{pipeline.timeText}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toast.success('开始执行', { description: pipeline.name }); }}
|
||||
<div className="flex items-center gap-0 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
runMutation.mutate(pipeline.raw.id);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="执行"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="配置"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectPipeline(pipeline.id);
|
||||
}}
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="删除"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(pipeline);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
@@ -321,7 +435,7 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
</div>
|
||||
) : (
|
||||
/* List view */
|
||||
<div
|
||||
<div
|
||||
className="rounded-lg border overflow-hidden"
|
||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||
>
|
||||
@@ -337,21 +451,21 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((pipeline) => {
|
||||
const status = statusConfig[pipeline.lastStatus];
|
||||
const status = pipeline.status;
|
||||
const StatusIcon = status.icon;
|
||||
return (
|
||||
<tr
|
||||
key={pipeline.id}
|
||||
<tr
|
||||
key={pipeline.id}
|
||||
className="border-t cursor-pointer hover:bg-black/[0.02] transition-colors"
|
||||
style={{ borderColor: 'var(--border-color-light)' }}
|
||||
onClick={() => onSelectPipeline(pipeline.id)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: status.color }} />
|
||||
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${pipeline.isRunning ? 'animate-pulse' : ''}`} style={{ background: status.color }} />
|
||||
<div>
|
||||
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{pipeline.name}</div>
|
||||
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{pipeline.description}</div>
|
||||
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{pipeline.description || '暂无描述'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -362,31 +476,65 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-[2px] rounded font-medium uppercase"
|
||||
style={{
|
||||
background: pipeline.deployType === 'k8s' ? '#ddf4ff' : '#dafbe1',
|
||||
color: pipeline.deployType === 'k8s' ? '#0969da' : '#1a7f37',
|
||||
}}
|
||||
>
|
||||
{pipeline.deployType === 'k8s' ? 'K8s' : '主机'}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-[2px] rounded font-medium uppercase"
|
||||
style={{ background: pipeline.deploy.bg, color: pipeline.deploy.color }}
|
||||
>
|
||||
{pipeline.deploy.label}
|
||||
</span>
|
||||
{pipeline.type === 'jenkins' && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-[2px] rounded font-medium"
|
||||
style={{ background: '#fbefff', color: '#8250df' }}
|
||||
>
|
||||
Jenkins
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusIcon className={`w-3.5 h-3.5 ${pipeline.lastStatus === 'running' ? 'animate-spin' : ''}`} style={{ color: status.color }} />
|
||||
<StatusIcon className={`w-3.5 h-3.5 ${pipeline.isRunning ? 'animate-pulse' : ''}`} style={{ color: status.color }} />
|
||||
<span className="text-[12px]" style={{ color: status.color }}>{status.label}</span>
|
||||
</div>
|
||||
<div className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }} title={pipeline.timeAgoText || undefined}>
|
||||
{pipeline.timeText}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{pipeline.duration}</td>
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{pipeline.triggerType}</td>
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{pipeline.durationText}</td>
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{pipeline.triggerText}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-0">
|
||||
<button onClick={(e) => { e.stopPropagation(); toast.success('开始执行'); }} className="p-1 rounded hover:bg-black/5">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
runMutation.mutate(pipeline.raw.id);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="执行"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
|
||||
</button>
|
||||
<button className="p-1 rounded hover:bg-black/5">
|
||||
<MoreHorizontal className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<button
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="配置"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectPipeline(pipeline.id);
|
||||
}}
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="删除"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(pipeline);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -398,6 +546,107 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 新建流水线弹窗 */}
|
||||
<Dialog open={createOpen} onOpenChange={(open) => { setCreateOpen(open); if (!open) setForm(emptyForm); }}>
|
||||
<DialogContent className="sm:max-w-md" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle style={{ color: 'var(--text-primary)' }}>新建流水线</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>
|
||||
名称 <span style={{ color: '#cf222e' }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
placeholder="例如 aiops-backend"
|
||||
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>描述</label>
|
||||
<input
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||
placeholder="例如 Go后端服务 - Docker构建 + K8s部署"
|
||||
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>代码仓库</label>
|
||||
<input
|
||||
value={form.sourceRepo}
|
||||
onChange={(e) => setForm((f) => ({ ...f, sourceRepo: e.target.value }))}
|
||||
placeholder="https://gitee.com/xxx.git"
|
||||
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>默认分支</label>
|
||||
<input
|
||||
value={form.sourceBranch}
|
||||
onChange={(e) => setForm((f) => ({ ...f, sourceBranch: e.target.value }))}
|
||||
placeholder="main"
|
||||
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>部署类型</label>
|
||||
<Select value={form.deployType} onValueChange={(v) => setForm((f) => ({ ...f, deployType: v }))}>
|
||||
<SelectTrigger className="w-full text-[13px]" style={inputStyle}>
|
||||
<SelectValue placeholder="选择部署类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<SelectItem value="host">主机</SelectItem>
|
||||
<SelectItem value="k8s">K8s</SelectItem>
|
||||
<SelectItem value="gitops">GitOps</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>环境</label>
|
||||
<Select value={form.env} onValueChange={(v) => setForm((f) => ({ ...f, env: v }))}>
|
||||
<SelectTrigger className="w-full text-[13px]" style={inputStyle}>
|
||||
<SelectValue placeholder="选择环境" />
|
||||
</SelectTrigger>
|
||||
<SelectContent style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<SelectItem value="dev">开发 (dev)</SelectItem>
|
||||
<SelectItem value="test">测试 (test)</SelectItem>
|
||||
<SelectItem value="staging">预发 (staging)</SelectItem>
|
||||
<SelectItem value="production">生产 (production)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<button
|
||||
onClick={() => setCreateOpen(false)}
|
||||
className="px-4 py-[7px] text-[13px] font-medium rounded-md border"
|
||||
style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={submitCreate}
|
||||
disabled={!form.name.trim() || createMutation.isPending}
|
||||
className="px-4 py-[7px] text-[13px] font-medium rounded-md text-white disabled:opacity-50"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
>
|
||||
{createMutation.isPending ? '创建中...' : '创建'}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user