refactor(quality): Q15 PipelineEditor 巨型组件拆分 (676L→406L, -40%), 提取 types + TaskCard + TaskEditorPanel + PipelineLogDialog + RunsHistoryTab + PipelineMembersTab 6 个独立文件, 零逻辑变更, ESLint errors=0, tsc -b=0, npm build✓, 17/17 测试通过
This commit is contained in:
1 parent
02f3d75f56
commit
1f3b419d62
9 files changed
+698
-295
No files matched your search
@@ -129,7 +129,7 @@
|
||||
| ~~`api.ts` 1324 行/352 方法~~ → 已拆分为 12 个域文件 (api/kubernetes.ts, deploy.ts, monitor.ts 等) | ✅ 已解决 | 前端 | ~~P0~~ done |
|
||||
| 前端 **1026 处** `any` 类型 | 类型安全缺失,重构风险高 | 前端全局 | ~~P0~~ **done** (any 1076→40, unused 25→6, console 93→1, tsc --noEmit=0) |
|
||||
| ~~K8s 双路由体系(集群域+Kuboard 域)~~ → code 路由统一为主,ID 路由标记 deprecated 向后兼容 | ✅ 已解决 | 后端 | ~~P1~~ done |
|
||||
| 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 → **Q6+Q7+Q8+Q9+Q10+Q11+Q12+Q13+Q14 done: 最大组件 ResourceVisualEditor 882L→282L (-68%) + AgentCLI 814L→521L (-36%) + SystemSettingsTab 754L→173L (-77%) + NamespaceResourcePage 719L→300L (-58%) + ApprovalWorkflowPage 705L→167L (-76%) + PipelineRunDetail 678L→273L (-60%) + CloudServerImport 699L→404L (-42%,any[]→具体接口) + UnifiedQueryPanel 682L→529L (-22%) + ServerManager 652L→442L (-32%),累计提取 39 个独立组件文件** |
|
||||
| 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 → **Q6-Q15 done: 最大组件 ResourceVisualEditor 882L→282L (-68%) + AgentCLI 814L→521L (-36%) + SystemSettingsTab 754L→173L (-77%) + NamespaceResourcePage 719L→300L (-58%) + ApprovalWorkflowPage 705L→167L (-76%) + PipelineRunDetail 678L→273L (-60%) + CloudServerImport 699L→404L (-42%,any[]→具体接口) + UnifiedQueryPanel 682L→529L (-22%) + ServerManager 652L→442L (-32%) + PipelineEditor 676L→406L (-40%),累计提取 45 个独立组件文件** |
|
||||
|
||||
### 3.2 🟡 中优先级
|
||||
|
||||
|
||||
+38
@@ -486,6 +486,7 @@ services/api.ts (1324L)
|
||||
| Q12 | 前端巨型组件拆分(CloudServerImport 699L→404L,提取 StepIndicator + 4 个向导步骤组件 + types,any[]→具体接口) | P0 | **done** |
|
||||
| Q13 | 前端巨型组件拆分(UnifiedQueryPanel 682L→529L,提取 constants + entry-mappers + 5 个 UI 子组件) | P0 | **done** |
|
||||
| Q14 | 前端巨型组件拆分(ServerManager 652L→442L,提取 ServerTableSection + server-export-utils) | P0 | **done** |
|
||||
| Q15 | 前端巨型组件拆分(PipelineEditor 676L→406L,提取 types + TaskCard + TaskEditorPanel + PipelineLogDialog + RunsHistoryTab + PipelineMembersTab) | P0 | **done** |
|
||||
|
||||
---
|
||||
|
||||
@@ -920,3 +921,40 @@ pending → in_progress → done / blocked
|
||||
- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200)
|
||||
- [x] 服务器管理页功能不变(列表/搜索/筛选/分页/选择/批量删除/CSV 导出/创建编辑/终端/RDP/文件浏览全部正常)
|
||||
|
||||
---
|
||||
|
||||
## Q15: 前端巨型组件拆分 — PipelineEditor [done]
|
||||
|
||||
**问题**: `PipelineEditor.tsx`(676 行)是 Q14 拆分后项目最大的核心业务组件之一(排除 UI 库 sidebar/data-table),包含流水线编辑器页面的全部逻辑:类型定义 + 常量 + 工具函数(Task/Stage/LogEntry 接口 + taskIcons 映射 + uid/fmtDuration)、任务卡片组件、任务编辑面板、日志弹窗、以及主组件(状态管理 + 数据加载 + 事件处理 + 5 个 Tab 的 JSX:流程配置/触发器/变量/发布记录/成员管理)。单文件过大导致维护困难、代码导航低效。
|
||||
|
||||
**已完成 (2026-07-08)**:
|
||||
|
||||
将类型/常量/工具、任务卡片、任务编辑面板、日志弹窗、发布记录 Tab、成员管理 Tab 提取为 `pipeline-editor/` 子目录下的独立文件:
|
||||
|
||||
| 新文件 | 行数 | 职责 |
|
||||
|--------|------|------|
|
||||
| `types.ts` | 66 | Task/Stage/LogEntry 接口 + stageTypeLabelKeys/taskIcons 常量 + uid/fmtDuration 工具函数 |
|
||||
| `TaskCard.tsx` | 58 | 任务卡片展示组件(选中态 + 图标 + 名称/类型 + 悬浮删除按钮) |
|
||||
| `TaskEditorPanel.tsx` | 91 | 任务编辑面板(名称/类型/图标选择 + 脚本类型切换 + 脚本内容编辑) |
|
||||
| `PipelineLogDialog.tsx` | 71 | 流水线运行日志弹窗(运行元信息 + 彩色日志渲染 + 空状态) |
|
||||
| `RunsHistoryTab.tsx` | 177 | 发布记录 Tab(运行历史表格 + 状态徽章 + 触发方式 + 操作按钮) |
|
||||
| `PipelineMembersTab.tsx` | 172 | 成员管理 Tab(添加成员表单 + 直接用户列表 + 组授权列表) |
|
||||
|
||||
主文件 `PipelineEditor.tsx` 从 **676 行降至 406 行**(-40%),仅保留:20+ 个状态声明、数据加载(pipeline/members/runs/environments/buildNodes)、事件处理(task/stage CRUD + save/trigger/stop + member add/remove)、触发器 Tab + 变量 Tab JSX(与主组件状态紧耦合)、Flow Tab 编排(stages 列表 + TaskCard + TaskEditor 集成)、以及 5 个子组件的编排。
|
||||
|
||||
**提取原则**: types 为纯数据/类型(零 React 依赖,仅 lucide 图标类型引用);TaskCard 为受控展示组件(接收 task + selected + 回调);TaskEditorPanel 为受控编辑组件(接收 task + onUpdate 回调);PipelineLogDialog 为纯展示弹窗(接收 run + logs);RunsHistoryTab 为受控列表组件(接收 runs + onViewLog/onStop 回调);PipelineMembersTab 为受控管理组件(接收 members + 6 个状态/回调 props)。零逻辑变更。
|
||||
|
||||
**验收标准**:
|
||||
- [x] PipelineEditor.tsx 从 676 行降至 **406 行**(-40%)
|
||||
- [x] 6 个提取的文件全部 < 180 行(最大 RunsHistoryTab 177 行)
|
||||
- [x] `tsc -b` 零错误
|
||||
- [x] `npm run build` 零错误(✓ built in 16.35s)
|
||||
- [x] ESLint errors 保持 **0**(清理 1 个 unused import: Loader2)
|
||||
- [x] ESLint warnings 保持 **64**(无新增)
|
||||
- [x] 全局 ESLint any 保持 **0**
|
||||
- [x] `go build` 零错误
|
||||
- [x] 17/17 集成回归测试通过(零回归)
|
||||
- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200)
|
||||
- [x] 流水线编辑器页功能不变(流程配置/触发器/变量/发布记录/成员管理 5 个 Tab 全部正常)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
|
||||
Plus,
|
||||
Trash2,
|
||||
ChevronUp,
|
||||
@@ -10,26 +9,12 @@ import {
|
||||
ChevronRight,
|
||||
Save,
|
||||
Play,
|
||||
Loader2,
|
||||
GitBranch,
|
||||
Wrench,
|
||||
Server,
|
||||
FlaskConical,
|
||||
Zap,
|
||||
Terminal,
|
||||
FileCode,
|
||||
Container,
|
||||
Clock,
|
||||
Webhook,
|
||||
Hand,
|
||||
Check,
|
||||
X,
|
||||
StopCircle,
|
||||
ScrollText,
|
||||
Users,
|
||||
UserPlus,
|
||||
Shield,
|
||||
FileText,
|
||||
Server,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
@@ -38,10 +23,8 @@ import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent } from '@/components/ui/tabs';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { toast } from 'sonner';
|
||||
import { pipelineApi, deployApi, buildNodeApi, type BuildNode } from '@/api';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
@@ -49,124 +32,13 @@ import { PageShell } from '@/components/layout/PageShell';
|
||||
import { PageEmpty } from '@/components/layout/PageEmpty';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
// ---- types ----
|
||||
interface Task {
|
||||
id: string; name: string; type: string; icon: string;
|
||||
config: Record<string, string>; script: string; script_type: string;
|
||||
}
|
||||
interface Stage {
|
||||
id: string; name: string; type: string; tasks: Task[];
|
||||
}
|
||||
interface LogEntry {
|
||||
time: string; level: string; message: string; stage: string;
|
||||
}
|
||||
|
||||
const stageTypeLabelKeys: Record<string, string> = {
|
||||
source: 'deploy.stage.checkoutDesc',
|
||||
build: 'deploy.stage.build',
|
||||
deploy: 'deploy.stage.deploy',
|
||||
test: 'deploy.stage.test',
|
||||
custom: 'deploy.stage.custom',
|
||||
};
|
||||
const taskIcons: JsonData = { GitBranch, Wrench, Server, FlaskConical, Zap, Terminal, FileCode, Container };
|
||||
|
||||
function uid() { return `tsk-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; }
|
||||
function fmtDuration(s: number | undefined): string {
|
||||
if (!s) return '-';
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60); const sec = s % 60;
|
||||
return sec > 0 ? `${m}分${sec}秒` : `${m}分`;
|
||||
}
|
||||
|
||||
// ---- Task Card ----
|
||||
function TaskCard({ task, selected, onSelect, onDelete }: { task: Task; selected: boolean; onSelect: () => void; onDelete: () => void }) {
|
||||
const Icon = taskIcons[task.icon] || Wrench;
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
className={`flex items-center gap-3 p-3 rounded-md border cursor-pointer transition-all group ${
|
||||
selected ? 'border-[var(--dg-blue)] bg-[color-mix(in_srgb,var(--dg-blue)_12%,transparent)]' : 'border-[var(--dg-border)] hover:border-[var(--dg-border-hover)] bg-[var(--dg-bg-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<div className={`p-1.5 rounded ${selected ? 'bg-[color-mix(in_srgb,var(--dg-blue)_15%,transparent)]' : 'bg-[var(--dg-bg-tertiary)]'}`}>
|
||||
<Icon className={`w-4 h-4 ${selected ? 'text-blue-600' : 'text-[var(--dg-text-secondary)]'}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-[var(--dg-text-primary)] truncate">{task.name}</div>
|
||||
<div className="text-xs text-[var(--dg-text-secondary)]">{task.type}</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-0 group-hover:opacity-100" onClick={e => { e.stopPropagation(); onDelete(); }}>
|
||||
<Trash2 className="w-3.5 h-3.5 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Task Editor Panel ----
|
||||
function TaskEditor({ task, onUpdate }: { task: Task | null; onUpdate: (t: Task) => void; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
if (!task) return <div className="p-6 text-center text-[var(--dg-text-secondary)] text-sm">{t('deploy.pipeline.selectTaskHint')}</div>;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1"><Label className="text-xs">{t('pipeline.taskName')}</Label><Input value={task.name} onChange={e => onUpdate({ ...task, name: e.target.value })} className="h-8 text-sm" /></div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1"><Label className="text-xs">{t('common.type')}</Label>
|
||||
<Select value={task.type} onValueChange={v => onUpdate({ ...task, type: v })}><SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="build">{t('pipeline.build')}</SelectItem><SelectItem value="deploy">{t('pipeline.deploy')}</SelectItem><SelectItem value="test">{t('pipeline.test')}</SelectItem><SelectItem value="custom">{t('pipeline.custom')}</SelectItem></SelectContent></Select>
|
||||
</div>
|
||||
<div className="space-y-1"><Label className="text-xs">{t('pipeline.icon')}</Label>
|
||||
<Select value={task.icon} onValueChange={v => onUpdate({ ...task, icon: v })}><SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{Object.entries(taskIcons).map(([k]) => (<SelectItem key={k} value={k}>{k}</SelectItem>))}</SelectContent></Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1"><Label className="text-xs">{t('automation.scriptType')}</Label>
|
||||
<div className="flex gap-2">{['shell', 'python'].map(s => (<Badge key={s} variant={task.script_type === s ? 'default' : 'outline'} className="cursor-pointer text-xs" onClick={() => onUpdate({ ...task, script_type: s })}>{s}</Badge>))}</div>
|
||||
</div>
|
||||
<div className="space-y-1"><Label className="text-xs">{t('automation.scriptContent')}</Label>
|
||||
<Textarea value={task.script} onChange={e => onUpdate({ ...task, script: e.target.value })} className="font-mono text-xs min-h-[200px] bg-[var(--dg-bg-primary)] dg-text-green" placeholder="#!/bin/bash\nset -e\necho 'hello'" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ====== Log Dialog ======
|
||||
function LogDialog({ open, onClose, run, logs }: { open: boolean; onClose: () => void; run?: JsonData; logs: LogEntry[] }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader><DialogTitle className="flex items-center gap-2"><ScrollText className="w-5 h-5" />{t('automation.execLog')} #{run?.build_number || run?.id}</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-3 text-sm text-[var(--dg-text-secondary)]">
|
||||
<span>{t('pipeline.triggerMode')}: {run?.triggered_by || 'manual'}</span>
|
||||
<span>{t('pipeline.branch')}: {run?.branch || '-'}</span>
|
||||
<span>{t('pipeline.duration')}: {fmtDuration(run?.duration)}</span>
|
||||
<span>{run?.created_at ? new Date(run.created_at).toLocaleString('zh-CN') : '-'}</span>
|
||||
</div>
|
||||
<div className="bg-[var(--dg-bg-primary)] rounded-md p-3 max-h-[50vh] overflow-y-auto font-mono text-xs">
|
||||
{logs.length === 0 ? (
|
||||
<div className="dg-text-muted py-4 text-center">{t('pipeline.noLog')}</div>
|
||||
) : (
|
||||
logs.map((l, i) => (
|
||||
<div key={i} className="flex gap-2 py-0.5">
|
||||
<span className="dg-text-muted shrink-0">[{l.time}]</span>
|
||||
<span className={
|
||||
l.level === 'error' ? 'dg-text-red' :
|
||||
l.level === 'warn' ? 'dg-text-yellow' :
|
||||
l.level === 'success' ? 'dg-text-green' :
|
||||
'text-[var(--dg-text-secondary)]'
|
||||
}>
|
||||
{l.level === 'success' ? '✓' : l.level === 'error' ? '✗' : l.level === 'warn' ? '!' : '>'} {l.message}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
import type { Task, Stage, LogEntry } from './pipeline-editor/types';
|
||||
import { stageTypeLabelKeys, uid } from './pipeline-editor/types';
|
||||
import { TaskCard } from './pipeline-editor/TaskCard';
|
||||
import { TaskEditor } from './pipeline-editor/TaskEditorPanel';
|
||||
import { PipelineLogDialog } from './pipeline-editor/PipelineLogDialog';
|
||||
import { RunsHistoryTab } from './pipeline-editor/RunsHistoryTab';
|
||||
import { PipelineMembersTab } from './pipeline-editor/PipelineMembersTab';
|
||||
|
||||
// ====== Main Component ======
|
||||
export default function PipelineEditor() {
|
||||
@@ -182,7 +54,7 @@ export default function PipelineEditor() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [runs, setRuns] = useState<JsonData[]>([]);
|
||||
const [environments, setEnvironments] = useState<JsonData[]>([]);
|
||||
const [members, setMembers] = useState<JsonData>([]);
|
||||
const [members, setMembers] = useState<JsonData[]>([]);
|
||||
const [loadingMembers, setLoadingMembers] = useState(false);
|
||||
|
||||
// trigger config
|
||||
@@ -234,7 +106,6 @@ export default function PipelineEditor() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const p = await pipelineApi.getPipelineDetail(pipelineCode) as JsonData;
|
||||
// http.get 已自动解包 json.data,p 即为 pipeline 对象
|
||||
if (!p || !p.id) { setPipeline(null); setLoading(false); return; }
|
||||
setPipeline(p);
|
||||
setTriggerMode(p.trigger_mode || 'manual');
|
||||
@@ -251,7 +122,6 @@ export default function PipelineEditor() {
|
||||
const s = p.stages ? JSON.parse(p.stages) : [];
|
||||
const stageList = Array.isArray(s) ? s : [];
|
||||
setStages(stageList);
|
||||
// Auto-expand all stages initially
|
||||
const expanded: Record<string, boolean> = {};
|
||||
stageList.forEach((stg: Stage) => { expanded[stg.id] = true; });
|
||||
setExpandedStages(expanded);
|
||||
@@ -434,7 +304,7 @@ export default function PipelineEditor() {
|
||||
<div className="text-center py-12 border-2 border-dashed rounded-lg text-[var(--dg-text-secondary)]">{t('deploy.pipeline.clickToAddStage')}</div>
|
||||
) : (
|
||||
stages.map((stage, idx) => {
|
||||
const isExpanded = expandedStages[stage.id] !== false; // default expanded
|
||||
const isExpanded = expandedStages[stage.id] !== false;
|
||||
return (
|
||||
<div key={stage.id} className="space-y-1">
|
||||
<div
|
||||
@@ -470,7 +340,7 @@ export default function PipelineEditor() {
|
||||
</div>
|
||||
<div className="w-[360px] flex-shrink-0 border border-[var(--dg-border)] rounded-lg p-4 bg-[var(--dg-bg-secondary)]">
|
||||
<div className="flex items-center justify-between mb-3"><span className="text-sm font-medium text-[var(--dg-text-primary)]">{t('pipeline.taskEdit')}</span>{selectedTask && <Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => setSelectedTaskId(null)}>{t('common.close')}</Button>}</div>
|
||||
<TaskEditor task={selectedTask} onUpdate={handleUpdateTask} onClose={() => setSelectedTaskId(null)} />
|
||||
<TaskEditor task={selectedTask} onUpdate={handleUpdateTask} />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -511,166 +381,26 @@ export default function PipelineEditor() {
|
||||
|
||||
{/* ====== Runs Tab ====== */}
|
||||
<TabsContent value="runs" className="mt-3">
|
||||
{runs.length === 0 ? (
|
||||
<div className="text-center py-12 text-[var(--dg-text-secondary)]">{t('pipeline.noRecords')}</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">#</th>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">{t('common.status')}</th>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">{t('pipeline.triggerMode')}</th>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">{t('pipeline.triggerBy')}</th>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">{t('pipeline.buildNode')}</th>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">{t('pipeline.branch')}</th>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">{t('pipeline.duration')}</th>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">{t('common.createdAt')}</th>
|
||||
<th className="px-3 py-2 text-right text-[var(--dg-text-secondary)]">{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map((r: JsonData) => (
|
||||
<tr key={r.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<td className="px-3 py-2 font-medium text-[var(--dg-text-primary)]">#{r.build_number || r.id}</td>
|
||||
<td className="px-3 py-2">
|
||||
{r.status === 'success' ? <Badge className="bg-emerald-500/20 dg-text-green border-emerald-500/30"><Check className="w-3 h-3 mr-0.5" />{t('common.success')}</Badge> :
|
||||
r.status === 'failed' ? <Badge className="bg-red-500/20 dg-text-red border-red-500/30"><X className="w-3 h-3 mr-0.5" />{t('common.failed')}</Badge> :
|
||||
r.status === 'running' ? <Badge className="bg-blue-500/20 dg-text-blue border-blue-500/30"><Loader2 className="w-3 h-3 animate-spin mr-0.5" />{t('common.executing')}</Badge> :
|
||||
r.status === 'cancelled' ? <Badge className="bg-yellow-500/20 dg-text-yellow border-yellow-500/30"><StopCircle className="w-3 h-3 mr-0.5" />{t('pipeline.stopped')}</Badge> :
|
||||
<Badge variant="secondary" className="text-[var(--dg-text-secondary)]">{r.status}</Badge>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)] text-xs">
|
||||
{r.trigger_mode === 'manual' && <span className="flex items-center gap-1"><Hand className="w-3 h-3" />{t('deploy.trigger.manual')}</span>}
|
||||
{r.trigger_mode === 'cron' && <span className="flex items-center gap-1"><Clock className="w-3 h-3" />{t('deploy.trigger.cron')}</span>}
|
||||
{r.trigger_mode === 'webhook' && <span className="flex items-center gap-1"><Webhook className="w-3 h-3" />Webhook</span>}
|
||||
{r.trigger_mode === 'git_push' && <span className="flex items-center gap-1"><GitBranch className="w-3 h-3" />Git Push</span>}
|
||||
{!r.trigger_mode && (r.triggered_by || t('deploy.trigger.manual'))}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)] text-xs">{r.triggered_by || 'admin'}</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)] text-xs">{r.build_node_name || 'Master'}</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)] font-mono text-xs">{r.branch || '-'}</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)]">{fmtDuration(r.duration)}</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)] text-xs">{r.created_at ? new Date(r.created_at).toLocaleString('zh-CN') : '-'}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs dg-text-blue hover:text-blue-300" onClick={() => handleViewLog(r)}><FileText className="w-3.5 h-3.5 mr-1" />{t('pipeline.log')}</Button>
|
||||
{(r.status === 'running' || r.status === 'pending') && (
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs dg-text-red hover:dg-text-red" onClick={() => handleStop(r.id)}><StopCircle className="w-3.5 h-3.5 mr-1" />{t('common.stop')}</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<RunsHistoryTab runs={runs} onViewLog={handleViewLog} onStop={handleStop} />
|
||||
</TabsContent>
|
||||
|
||||
{/* ====== Members Tab ====== */}
|
||||
<TabsContent value="members" className="mt-3">
|
||||
<div className="max-w-xl space-y-4">
|
||||
{/* Add member form */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<UserPlus className="w-4 h-4 dg-text-blue" />
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)]">{t('pipeline.addUser')}</span>
|
||||
</div>
|
||||
<div className="flex items-end gap-3 mt-3">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label className="text-xs">{t('pipeline.username')}</Label>
|
||||
<Input value={newMemberUser} onChange={e => setNewMemberUser(e.target.value)} placeholder={t('pipeline.usernamePlaceholder')} className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('pipeline.role')}</Label>
|
||||
<Select value={newMemberRole} onValueChange={setNewMemberRole}>
|
||||
<SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="owner">{t('pipeline.owner')}</SelectItem>
|
||||
<SelectItem value="editor">{t('pipeline.editor')}</SelectItem>
|
||||
<SelectItem value="viewer">{t('pipeline.viewer')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={handleAddMember} className="h-9"><Plus className="w-4 h-4 mr-1" />{t('common.add')}</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Members list — split direct users vs group assignments */}
|
||||
{(() => {
|
||||
const directUsers = members.filter((m: JsonData) => m.username);
|
||||
const groupAssignments = members.filter((m: JsonData) => m.group_id);
|
||||
return (
|
||||
<>
|
||||
{/* Direct users */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-0">
|
||||
<div className="px-4 py-2 text-xs font-medium text-[var(--dg-text-secondary)] border-b border-[var(--dg-border)]">
|
||||
👤 {t('pipeline.directUserAuth')} ({directUsers.length})
|
||||
</div>
|
||||
{loadingMembers ? (
|
||||
<div className="flex items-center justify-center py-6"><Loader2 className="w-5 h-5 animate-spin text-[var(--dg-text-secondary)]" /></div>
|
||||
) : directUsers.length === 0 ? (
|
||||
<div className="py-4 text-center text-[var(--dg-text-secondary)] text-xs">{t('pipeline.noDirectUser')}</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--dg-border)]">
|
||||
{directUsers.map((m: JsonData) => (
|
||||
<div key={m.id} className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center"><Users className="w-4 h-4 dg-text-blue" /></div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[var(--dg-text-primary)]">{m.username}</div>
|
||||
<div className="text-xs text-[var(--dg-text-secondary)]">{m.role === 'owner' ? <span className="dg-text-yellow">{t('pipeline.owner')}</span> : m.role === 'editor' ? <span className="dg-text-blue">{t('pipeline.editor')}</span> : <span className="dg-text-secondary">{t('pipeline.viewer')}</span>}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs dg-text-red hover:dg-text-red" onClick={() => handleRemoveMember(m.id)}><Trash2 className="w-3.5 h-3.5 mr-1" />{t('pipeline.removeMember')}</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Group assignments */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-0">
|
||||
<div className="px-4 py-2 text-xs font-medium text-[var(--dg-text-secondary)] border-b border-[var(--dg-border)]">
|
||||
👥 {t('pipeline.groupAuth')} ({groupAssignments.length})
|
||||
</div>
|
||||
{groupAssignments.length === 0 ? (
|
||||
<div className="py-4 text-center text-[var(--dg-text-secondary)] text-xs">
|
||||
{t('pipeline.noGroupAuthHint', { defaultValue: t('pipeline.noGroupAuthHint') })}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--dg-border)]">
|
||||
{groupAssignments.map((m: JsonData) => (
|
||||
<div key={m.id} className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-purple-500/20 flex items-center justify-center"><Shield className="w-4 h-4 dg-text-purple" /></div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[var(--dg-text-primary)]">{m.group?.name || `${t('pipeline.groupLabel')} #${m.group_id}`}</div>
|
||||
<div className="text-xs text-[var(--dg-text-secondary)]">{m.role === 'owner' ? <span className="dg-text-yellow">{t('pipeline.owner')}</span> : m.role === 'editor' ? <span className="dg-text-blue">{t('pipeline.editor')}</span> : <span className="dg-text-secondary">{t('pipeline.viewer')}</span>} · {t('pipeline.allGroupMembers')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs dg-text-red hover:dg-text-red" onClick={() => handleRemoveMember(m.id)}><Trash2 className="w-3.5 h-3.5 mr-1" />{t('pipeline.revokeAuth')}</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<PipelineMembersTab
|
||||
members={members}
|
||||
loadingMembers={loadingMembers}
|
||||
newMemberUser={newMemberUser}
|
||||
setNewMemberUser={setNewMemberUser}
|
||||
newMemberRole={newMemberRole}
|
||||
setNewMemberRole={setNewMemberRole}
|
||||
onAddMember={handleAddMember}
|
||||
onRemoveMember={handleRemoveMember}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Log Dialog */}
|
||||
<LogDialog open={logOpen} onClose={() => setLogOpen(false)} run={logRun} logs={logs} />
|
||||
<PipelineLogDialog open={logOpen} onClose={() => setLogOpen(false)} run={logRun} logs={logs} />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ScrollText } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import type { LogEntry } from './types';
|
||||
import { fmtDuration } from './types';
|
||||
|
||||
export function PipelineLogDialog({
|
||||
open,
|
||||
onClose,
|
||||
run,
|
||||
logs,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
run?: JsonData;
|
||||
logs: LogEntry[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ScrollText className="w-5 h-5" />
|
||||
{t('automation.execLog')} #{run?.build_number || run?.id}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-3 text-sm text-[var(--dg-text-secondary)]">
|
||||
<span>
|
||||
{t('pipeline.triggerMode')}: {run?.triggered_by || 'manual'}
|
||||
</span>
|
||||
<span>
|
||||
{t('pipeline.branch')}: {run?.branch || '-'}
|
||||
</span>
|
||||
<span>
|
||||
{t('pipeline.duration')}: {fmtDuration(run?.duration)}
|
||||
</span>
|
||||
<span>{run?.created_at ? new Date(run.created_at).toLocaleString('zh-CN') : '-'}</span>
|
||||
</div>
|
||||
<div className="bg-[var(--dg-bg-primary)] rounded-md p-3 max-h-[50vh] overflow-y-auto font-mono text-xs">
|
||||
{logs.length === 0 ? (
|
||||
<div className="dg-text-muted py-4 text-center">{t('pipeline.noLog')}</div>
|
||||
) : (
|
||||
logs.map((l, i) => (
|
||||
<div key={i} className="flex gap-2 py-0.5">
|
||||
<span className="dg-text-muted shrink-0">[{l.time}]</span>
|
||||
<span
|
||||
className={
|
||||
l.level === 'error'
|
||||
? 'dg-text-red'
|
||||
: l.level === 'warn'
|
||||
? 'dg-text-yellow'
|
||||
: l.level === 'success'
|
||||
? 'dg-text-green'
|
||||
: 'text-[var(--dg-text-secondary)]'
|
||||
}
|
||||
>
|
||||
{l.level === 'success' ? '✓' : l.level === 'error' ? '✗' : l.level === 'warn' ? '!' : '>'}{' '}
|
||||
{l.message}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UserPlus, Users, Shield, Trash2, Loader2 } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
export function PipelineMembersTab({
|
||||
members,
|
||||
loadingMembers,
|
||||
newMemberUser,
|
||||
setNewMemberUser,
|
||||
newMemberRole,
|
||||
setNewMemberRole,
|
||||
onAddMember,
|
||||
onRemoveMember,
|
||||
}: {
|
||||
members: JsonData[];
|
||||
loadingMembers: boolean;
|
||||
newMemberUser: string;
|
||||
setNewMemberUser: (v: string) => void;
|
||||
newMemberRole: string;
|
||||
setNewMemberRole: (v: string) => void;
|
||||
onAddMember: () => void;
|
||||
onRemoveMember: (memberId: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const directUsers = members.filter((m: JsonData) => m.username);
|
||||
const groupAssignments = members.filter((m: JsonData) => m.group_id);
|
||||
|
||||
const renderRoleLabel = (role: string) => {
|
||||
if (role === 'owner') return <span className="dg-text-yellow">{t('pipeline.owner')}</span>;
|
||||
if (role === 'editor') return <span className="dg-text-blue">{t('pipeline.editor')}</span>;
|
||||
return <span className="dg-text-secondary">{t('pipeline.viewer')}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-xl space-y-4">
|
||||
{/* Add member form */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<UserPlus className="w-4 h-4 dg-text-blue" />
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)]">
|
||||
{t('pipeline.addUser')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-end gap-3 mt-3">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label className="text-xs">{t('pipeline.username')}</Label>
|
||||
<Input
|
||||
value={newMemberUser}
|
||||
onChange={(e) => setNewMemberUser(e.target.value)}
|
||||
placeholder={t('pipeline.usernamePlaceholder')}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('pipeline.role')}</Label>
|
||||
<Select value={newMemberRole} onValueChange={setNewMemberRole}>
|
||||
<SelectTrigger className="h-9 w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="owner">{t('pipeline.owner')}</SelectItem>
|
||||
<SelectItem value="editor">{t('pipeline.editor')}</SelectItem>
|
||||
<SelectItem value="viewer">{t('pipeline.viewer')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={onAddMember} className="h-9">
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Direct users */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-0">
|
||||
<div className="px-4 py-2 text-xs font-medium text-[var(--dg-text-secondary)] border-b border-[var(--dg-border)]">
|
||||
👤 {t('pipeline.directUserAuth')} ({directUsers.length})
|
||||
</div>
|
||||
{loadingMembers ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-[var(--dg-text-secondary)]" />
|
||||
</div>
|
||||
) : directUsers.length === 0 ? (
|
||||
<div className="py-4 text-center text-[var(--dg-text-secondary)] text-xs">
|
||||
{t('pipeline.noDirectUser')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--dg-border)]">
|
||||
{directUsers.map((m: JsonData) => (
|
||||
<div key={m.id} className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center">
|
||||
<Users className="w-4 h-4 dg-text-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[var(--dg-text-primary)]">
|
||||
{m.username}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--dg-text-secondary)]">
|
||||
{renderRoleLabel(m.role)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs dg-text-red hover:dg-text-red"
|
||||
onClick={() => onRemoveMember(m.id)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 mr-1" />
|
||||
{t('pipeline.removeMember')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Group assignments */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-0">
|
||||
<div className="px-4 py-2 text-xs font-medium text-[var(--dg-text-secondary)] border-b border-[var(--dg-border)]">
|
||||
👥 {t('pipeline.groupAuth')} ({groupAssignments.length})
|
||||
</div>
|
||||
{groupAssignments.length === 0 ? (
|
||||
<div className="py-4 text-center text-[var(--dg-text-secondary)] text-xs">
|
||||
{t('pipeline.noGroupAuthHint', { defaultValue: t('pipeline.noGroupAuthHint') })}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--dg-border)]">
|
||||
{groupAssignments.map((m: JsonData) => (
|
||||
<div key={m.id} className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-purple-500/20 flex items-center justify-center">
|
||||
<Shield className="w-4 h-4 dg-text-purple" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[var(--dg-text-primary)]">
|
||||
{m.group?.name || `${t('pipeline.groupLabel')} #${m.group_id}`}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--dg-text-secondary)]">
|
||||
{renderRoleLabel(m.role)} · {t('pipeline.allGroupMembers')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs dg-text-red hover:dg-text-red"
|
||||
onClick={() => onRemoveMember(m.id)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 mr-1" />
|
||||
{t('pipeline.revokeAuth')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
StopCircle,
|
||||
Hand,
|
||||
Clock,
|
||||
Webhook,
|
||||
GitBranch,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import { fmtDuration } from './types';
|
||||
|
||||
export function RunsHistoryTab({
|
||||
runs,
|
||||
onViewLog,
|
||||
onStop,
|
||||
}: {
|
||||
runs: JsonData[];
|
||||
onViewLog: (run: JsonData) => void;
|
||||
onStop: (runId: number | string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (runs.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-[var(--dg-text-secondary)]">
|
||||
{t('pipeline.noRecords')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">#</th>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">
|
||||
{t('common.status')}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">
|
||||
{t('pipeline.triggerMode')}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">
|
||||
{t('pipeline.triggerBy')}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">
|
||||
{t('pipeline.buildNode')}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">
|
||||
{t('pipeline.branch')}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">
|
||||
{t('pipeline.duration')}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">
|
||||
{t('common.createdAt')}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right text-[var(--dg-text-secondary)]">
|
||||
{t('common.actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map((r: JsonData) => (
|
||||
<tr
|
||||
key={r.id}
|
||||
className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50"
|
||||
>
|
||||
<td className="px-3 py-2 font-medium text-[var(--dg-text-primary)]">
|
||||
#{r.build_number || r.id}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{r.status === 'success' ? (
|
||||
<Badge className="bg-emerald-500/20 dg-text-green border-emerald-500/30">
|
||||
<Check className="w-3 h-3 mr-0.5" />
|
||||
{t('common.success')}
|
||||
</Badge>
|
||||
) : r.status === 'failed' ? (
|
||||
<Badge className="bg-red-500/20 dg-text-red border-red-500/30">
|
||||
<X className="w-3 h-3 mr-0.5" />
|
||||
{t('common.failed')}
|
||||
</Badge>
|
||||
) : r.status === 'running' ? (
|
||||
<Badge className="bg-blue-500/20 dg-text-blue border-blue-500/30">
|
||||
<Loader2 className="w-3 h-3 animate-spin mr-0.5" />
|
||||
{t('common.executing')}
|
||||
</Badge>
|
||||
) : r.status === 'cancelled' ? (
|
||||
<Badge className="bg-yellow-500/20 dg-text-yellow border-yellow-500/30">
|
||||
<StopCircle className="w-3 h-3 mr-0.5" />
|
||||
{t('pipeline.stopped')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-[var(--dg-text-secondary)]">
|
||||
{r.status}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)] text-xs">
|
||||
{r.trigger_mode === 'manual' && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Hand className="w-3 h-3" />
|
||||
{t('deploy.trigger.manual')}
|
||||
</span>
|
||||
)}
|
||||
{r.trigger_mode === 'cron' && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{t('deploy.trigger.cron')}
|
||||
</span>
|
||||
)}
|
||||
{r.trigger_mode === 'webhook' && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Webhook className="w-3 h-3" />
|
||||
Webhook
|
||||
</span>
|
||||
)}
|
||||
{r.trigger_mode === 'git_push' && (
|
||||
<span className="flex items-center gap-1">
|
||||
<GitBranch className="w-3 h-3" />
|
||||
Git Push
|
||||
</span>
|
||||
)}
|
||||
{!r.trigger_mode && (r.triggered_by || t('deploy.trigger.manual'))}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)] text-xs">
|
||||
{r.triggered_by || 'admin'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)] text-xs">
|
||||
{r.build_node_name || 'Master'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)] font-mono text-xs">
|
||||
{r.branch || '-'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)]">
|
||||
{fmtDuration(r.duration)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)] text-xs">
|
||||
{r.created_at ? new Date(r.created_at).toLocaleString('zh-CN') : '-'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs dg-text-blue hover:text-blue-300"
|
||||
onClick={() => onViewLog(r)}
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5 mr-1" />
|
||||
{t('pipeline.log')}
|
||||
</Button>
|
||||
{(r.status === 'running' || r.status === 'pending') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs dg-text-red hover:dg-text-red"
|
||||
onClick={() => onStop(r.id)}
|
||||
>
|
||||
<StopCircle className="w-3.5 h-3.5 mr-1" />
|
||||
{t('common.stop')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Wrench } from 'lucide-react';
|
||||
import type { Task } from './types';
|
||||
import { taskIcons } from './types';
|
||||
|
||||
export function TaskCard({
|
||||
task,
|
||||
selected,
|
||||
onSelect,
|
||||
onDelete,
|
||||
}: {
|
||||
task: Task;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const Icon = taskIcons[task.icon] || Wrench;
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
className={`flex items-center gap-3 p-3 rounded-md border cursor-pointer transition-all group ${
|
||||
selected
|
||||
? 'border-[var(--dg-blue)] bg-[color-mix(in_srgb,var(--dg-blue)_12%,transparent)]'
|
||||
: 'border-[var(--dg-border)] hover:border-[var(--dg-border-hover)] bg-[var(--dg-bg-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`p-1.5 rounded ${
|
||||
selected
|
||||
? 'bg-[color-mix(in_srgb,var(--dg-blue)_15%,transparent)]'
|
||||
: 'bg-[var(--dg-bg-tertiary)]'
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className={`w-4 h-4 ${selected ? 'text-blue-600' : 'text-[var(--dg-text-secondary)]'}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-[var(--dg-text-primary)] truncate">
|
||||
{task.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--dg-text-secondary)]">{task.type}</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import type { Task } from './types';
|
||||
import { taskIcons } from './types';
|
||||
|
||||
export function TaskEditor({
|
||||
task,
|
||||
onUpdate,
|
||||
}: {
|
||||
task: Task | null;
|
||||
onUpdate: (t: Task) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!task)
|
||||
return (
|
||||
<div className="p-6 text-center text-[var(--dg-text-secondary)] text-sm">
|
||||
{t('deploy.pipeline.selectTaskHint')}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('pipeline.taskName')}</Label>
|
||||
<Input
|
||||
value={task.name}
|
||||
onChange={(e) => onUpdate({ ...task, name: e.target.value })}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('common.type')}</Label>
|
||||
<Select value={task.type} onValueChange={(v) => onUpdate({ ...task, type: v })}>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="build">{t('pipeline.build')}</SelectItem>
|
||||
<SelectItem value="deploy">{t('pipeline.deploy')}</SelectItem>
|
||||
<SelectItem value="test">{t('pipeline.test')}</SelectItem>
|
||||
<SelectItem value="custom">{t('pipeline.custom')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('pipeline.icon')}</Label>
|
||||
<Select value={task.icon} onValueChange={(v) => onUpdate({ ...task, icon: v })}>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(taskIcons).map(([k]) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{k}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('automation.scriptType')}</Label>
|
||||
<div className="flex gap-2">
|
||||
{['shell', 'python'].map((s) => (
|
||||
<Badge
|
||||
key={s}
|
||||
variant={task.script_type === s ? 'default' : 'outline'}
|
||||
className="cursor-pointer text-xs"
|
||||
onClick={() => onUpdate({ ...task, script_type: s })}
|
||||
>
|
||||
{s}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('automation.scriptContent')}</Label>
|
||||
<Textarea
|
||||
value={task.script}
|
||||
onChange={(e) => onUpdate({ ...task, script: e.target.value })}
|
||||
className="font-mono text-xs min-h-[200px] bg-[var(--dg-bg-primary)] dg-text-green"
|
||||
placeholder="#!/bin/bash\nset -e\necho 'hello'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
GitBranch,
|
||||
Wrench,
|
||||
Server,
|
||||
FlaskConical,
|
||||
Zap,
|
||||
Terminal,
|
||||
FileCode,
|
||||
Container,
|
||||
} from 'lucide-react';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
icon: string;
|
||||
config: Record<string, string>;
|
||||
script: string;
|
||||
script_type: string;
|
||||
}
|
||||
|
||||
export interface Stage {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
time: string;
|
||||
level: string;
|
||||
message: string;
|
||||
stage: string;
|
||||
}
|
||||
|
||||
export const stageTypeLabelKeys: Record<string, string> = {
|
||||
source: 'deploy.stage.checkoutDesc',
|
||||
build: 'deploy.stage.build',
|
||||
deploy: 'deploy.stage.deploy',
|
||||
test: 'deploy.stage.test',
|
||||
custom: 'deploy.stage.custom',
|
||||
};
|
||||
|
||||
export const taskIcons: JsonData = {
|
||||
GitBranch,
|
||||
Wrench,
|
||||
Server,
|
||||
FlaskConical,
|
||||
Zap,
|
||||
Terminal,
|
||||
FileCode,
|
||||
Container,
|
||||
};
|
||||
|
||||
export function uid(): string {
|
||||
return `tsk-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
export function fmtDuration(s: number | undefined): string {
|
||||
if (!s) return '-';
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
return sec > 0 ? `${m}分${sec}秒` : `${m}分`;
|
||||
}
|
||||
Reference in new issue
Block a user