Files
aiops/frontend/src/components/deploy/PipelineEditor.tsx
T
Logan.Li fd60361d93 chore: 统一本地服务器与远程仓库源码
- 合并 main 与 master 历史并以服务器结构为基线
- 保留迁移、数据保留、AWS 账单与部署安全能力
- 建立 SDD 规格和 CI 验证闭环
2026-08-05 20:03:25 +08:00

677 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Plus,
Trash2,
ChevronUp,
ChevronDown,
ChevronRight,
Save,
Play,
Loader2,
GitBranch,
Wrench,
Server,
FlaskConical,
Zap,
Terminal,
FileCode,
Container,
Clock,
Webhook,
Hand,
Check,
X,
StopCircle,
ScrollText,
Users,
UserPlus,
Shield,
FileText,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
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 { toast } from 'sonner';
import { pipelineApi, deployApi, buildNodeApi, type BuildNode } from '@/api';
import { useConfirm } from '@/components/ui/confirm-dialog';
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>
);
}
// ====== Main Component ======
export default function PipelineEditor() {
const { t } = useTranslation();
const { code } = useParams<{ code: string }>();
const pipelineCode = code || '';
const [pipeline, setPipeline] = useState<JsonData>(null);
const [stages, setStages] = useState<Stage[]>([]);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [expandedStages, setExpandedStages] = useState<Record<string, boolean>>({});
const [activeTab, setActiveTab] = useState('flow');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [runs, setRuns] = useState<JsonData[]>([]);
const [environments, setEnvironments] = useState<JsonData[]>([]);
const [members, setMembers] = useState<JsonData>([]);
const [loadingMembers, setLoadingMembers] = useState(false);
// trigger config
const [triggerMode, setTriggerMode] = useState('manual');
const [watchBranches, setWatchBranches] = useState('');
const [cronExpr, setCronExpr] = useState('');
const [webhookToken, setWebhookToken] = useState('');
const [variables, setVariables] = useState<[string, string][]>([]);
const [environmentId, setEnvironmentId] = useState<number | string>('');
const [buildNodes, setBuildNodes] = useState<BuildNode[]>([]);
const [buildNodeId, setBuildNodeId] = useState('');
// member add dialog
const [newMemberUser, setNewMemberUser] = useState('');
const [newMemberRole, setNewMemberRole] = useState('editor');
// log dialog
const [logOpen, setLogOpen] = useState(false);
const [logRun, setLogRun] = useState<JsonData>(null);
const [logs, setLogs] = useState<LogEntry[]>([]);
const _confirm = useConfirm();
const loadEnvironments = useCallback(async () => {
try {
const res = await deployApi.getEnvironments();
setEnvironments(Array.isArray(res) ? res : (res as JsonData)?.list || []);
} catch { /* silent */ }
}, []);
const loadMembers = useCallback(async () => {
setLoadingMembers(true);
try {
const res = await pipelineApi.getMembers(pipelineCode);
setMembers(Array.isArray(res) ? res : (res as JsonData)?.data || []);
} catch { setMembers([]); }
finally { setLoadingMembers(false); }
}, [pipelineCode]);
const loadRuns = useCallback(async () => {
try {
const res = await pipelineApi.getRuns(pipelineCode);
setRuns(Array.isArray(res) ? res : (res as JsonData)?.list || []);
} catch { setRuns([]); }
}, [pipelineCode]);
useEffect(() => {
(async () => {
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');
setWatchBranches(p.watch_branches || '');
setCronExpr(p.cron_expr || '');
setWebhookToken(p.webhook_token || '');
setEnvironmentId(p.environment_id || '');
setBuildNodeId(p.build_node_id ? String(p.build_node_id) : '');
try {
const parsed = p.variables ? JSON.parse(p.variables) : {};
setVariables(Object.entries(parsed).map(([k, v]) => [k, String(v)]));
} catch { setVariables([]); }
try {
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);
} catch { setStages([]); }
} catch { toast.error(t('common.loadFailed')); }
finally { setLoading(false); }
})();
loadEnvironments();
loadMembers();
loadRuns();
buildNodeApi.list().then((res) => {
const data = res as { list?: BuildNode[] };
setBuildNodes(Array.isArray(data?.list) ? data.list.filter((n) => n.enabled) : []);
}).catch(() => { /* silent */ });
}, [pipelineCode, loadEnvironments, loadMembers, loadRuns, t]);
const selectedTask = stages.flatMap(s => s.tasks).find(t => t.id === selectedTaskId) || null;
const handleUpdateTask = (tk: Task) => { setStages(prev => prev.map(s => ({ ...s, tasks: s.tasks.map(x => x.id === tk.id ? tk : x) }))); };
const handleAddTask = (stageIdx: number) => {
const newTask: Task = { id: uid(), name: t('pipeline.newTask'), type: 'build', icon: 'Wrench', config: {}, script: '', script_type: 'shell' };
setStages(prev => prev.map((s, i) => i === stageIdx ? { ...s, tasks: [...s.tasks, newTask] } : s));
};
const handleDeleteTask = (taskId: string) => { setStages(prev => prev.map(s => ({ ...s, tasks: s.tasks.filter(t => t.id !== taskId) }))); if (selectedTaskId === taskId) setSelectedTaskId(null); };
const handleAddStage = () => { const newStage: Stage = { id: `stg-${Date.now()}`, name: t('pipeline.newStage'), type: 'custom', tasks: [] }; setStages(prev => [...prev, newStage]); };
const handleDeleteStage = (idx: number) => { setStages(prev => prev.filter((_, i) => i !== idx)); };
const handleMoveStage = (idx: number, dir: -1 | 1) => { const nx = idx + dir; if (nx < 0 || nx >= stages.length) return; const arr = [...stages]; [arr[idx], arr[nx]] = [arr[nx], arr[idx]]; setStages(arr); };
const handleSave = async () => {
setSaving(true);
try {
await pipelineApi.updatePipeline(pipelineCode, {
stages: JSON.stringify(stages),
trigger_mode: triggerMode,
watch_branches: watchBranches,
cron_expr: cronExpr,
webhook_token: webhookToken,
environment_id: environmentId ? Number(environmentId) : null,
variables: JSON.stringify(Object.fromEntries(variables.filter(([k]) => k.trim()))),
build_node_id: buildNodeId ? Number(buildNodeId) : null,
});
toast.success(t('common.savedSuccessfully'));
} catch { toast.error(t('common.saveFailed')); }
finally { setSaving(false); }
};
const handleTrigger = async () => {
try {
await pipelineApi.triggerPipeline(pipelineCode);
toast.success(t('deploy.pipeline.triggered'));
loadRuns();
} catch { toast.error(t('deploy.pipeline.triggerFailed')); }
};
const handleStop = async (runId: number | string) => {
const ok = await _confirm({ title: t('pipeline.stopRun'), description: t('deploy.run.stopConfirm'), variant: 'destructive' });
if (!ok) return;
try {
await pipelineApi.stopRun(pipelineCode, runId);
toast.success(t('pipeline.stopped'));
loadRuns();
} catch { toast.error(t('pipeline.stopFailed')); }
};
const handleViewLog = async (run: JsonData) => {
setLogRun(run);
setLogOpen(true);
try {
const data = await pipelineApi.getRunLog(pipelineCode, run.id) as JsonData;
setLogs(data?.logs || []);
} catch { setLogs([]); }
};
const handleAddMember = async () => {
if (!newMemberUser.trim()) { toast.error(t('pipeline.usernameRequired')); return; }
try {
await pipelineApi.addMember(pipelineCode, { username: newMemberUser.trim(), role: newMemberRole });
toast.success(t('pipeline.memberAdded'));
setNewMemberUser('');
loadMembers();
} catch (e) { toast.error(e?.message || t('common.operationFailed')); }
};
const handleRemoveMember = async (memberId: number) => {
const ok = await _confirm({ title: t('pipeline.removeMember'), description: t('pipeline.removeMemberConfirm'), variant: 'destructive' });
if (!ok) return;
try { await pipelineApi.removeMember(pipelineCode, memberId); toast.success(t('pipeline.memberRemoved')); loadMembers(); }
catch { toast.error(t('common.operationFailed')); }
};
const envBadge = environments.find(e => String(e.id) === String(environmentId));
const editorTabs = [
{ id: 'flow', label: t('pipeline.flowConfig') },
{ id: 'triggers', label: t('pipeline.triggerSettings') },
{ id: 'variables', label: t('pipeline.variables') },
{ id: 'runs', label: t('pipeline.releaseRecords'), badge: runs.length || undefined },
{ id: 'members', label: t('pipeline.memberManage'), badge: members.length || undefined },
];
if (!loading && !pipeline) {
return (
<PageShell variant="workspace" title={t('pipeline.pipelineEditor')} icon={<GitBranch className="h-5 w-5" />}>
<PageEmpty title={t('pipeline.pipelineNotExist')} description={t('pipeline.checkLinkOrRetry')} />
</PageShell>
);
}
return (
<PageShell
variant="workspace"
title={pipeline?.name || t('pipeline.pipelineEditor')}
subtitle={pipeline ? `code: ${pipeline.code} · ${pipeline.description || ''}` : undefined}
icon={<GitBranch className="h-5 w-5" />}
loading={loading}
tabs={editorTabs}
activeTab={activeTab}
onTabChange={setActiveTab}
actions={(
<>
{envBadge && (
<Badge style={{ backgroundColor: envBadge.color + '20', color: envBadge.color, borderColor: envBadge.color + '40' }} className="text-xs">
{envBadge.display_name}
</Badge>
)}
<Button variant="outline" className="dg-btn-secondary" onClick={handleSave} disabled={saving}>
<Save className="w-4 h-4 mr-1" />{saving ? t('common.loading') : t('common.save')}
</Button>
<Button className="dg-btn-primary" onClick={handleTrigger}>
<Play className="w-4 h-4 mr-1" />{t('pipeline.run')}
</Button>
</>
)}
>
{/* Environment Selector - quick bar */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-3 flex items-center gap-3">
<Shield className="w-4 h-4 text-[var(--dg-text-secondary)]" />
<span className="text-sm text-[var(--dg-text-secondary)]">{t('pipeline.deployEnv')}:</span>
<Select value={String(environmentId)} onValueChange={v => setEnvironmentId(v === '__none' ? '' : v)}>
<SelectTrigger className="w-40 h-8 text-sm"><SelectValue placeholder={t('pipeline.selectEnv')} /></SelectTrigger>
<SelectContent>
<SelectItem value="__none">{t('pipeline.unspecified')}</SelectItem>
{environments.map(env => (
<SelectItem key={env.id} value={String(env.id)}>
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full inline-block" style={{ backgroundColor: env.color }} />
{env.display_name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Server className="w-4 h-4 text-[var(--dg-text-secondary)] ml-2" />
<span className="text-sm text-[var(--dg-text-secondary)]">{t('pipeline.buildNode')}:</span>
<Select value={buildNodeId || '__auto'} onValueChange={v => setBuildNodeId(v === '__auto' ? '' : v)}>
<SelectTrigger className="w-48 h-8 text-sm"><SelectValue placeholder={t('pipeline.autoSchedule')} /></SelectTrigger>
<SelectContent>
<SelectItem value="__auto">{t('pipeline.autoScheduleMaster')}</SelectItem>
{buildNodes.map(n => (
<SelectItem key={n.id} value={String(n.id)}>
{n.name} ({n.running_count || 0}/{n.max_executors})
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-xs text-[var(--dg-text-secondary)] opacity-70">{t('pipeline.masterFullAutoSchedule')}</span>
</CardContent>
</Card>
<Tabs value={activeTab} onValueChange={setActiveTab}>
{/* ====== Flow Tab ====== */}
<TabsContent value="flow" className="mt-3">
<div className="flex gap-4">
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-[var(--dg-text-primary)]">{t('deploy.pipeline.stages', { count: stages.length })}</span>
<Button size="sm" onClick={handleAddStage}><Plus className="w-3.5 h-3.5 mr-1" />{t('pipeline.addStage')}</Button>
</div>
{stages.length === 0 ? (
<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
return (
<div key={stage.id} className="space-y-1">
<div
className="flex items-center gap-2 px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-t-md border border-b-0 border-[var(--dg-border)] cursor-pointer"
onClick={() => setExpandedStages(prev => ({ ...prev, [stage.id]: !isExpanded }))}
>
<Button variant="ghost" size="icon" className="h-5 w-5 p-0" onClick={(e) => { e.stopPropagation(); setExpandedStages(prev => ({ ...prev, [stage.id]: !isExpanded })); }}>
{isExpanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
</Button>
<Input
value={stage.name}
onChange={e => setStages(prev => prev.map((s, i) => i === idx ? { ...s, name: e.target.value } : s))}
className="h-7 text-sm font-medium border-none bg-transparent shadow-none flex-1 px-1"
onClick={(e) => e.stopPropagation()}
/>
<span className="text-xs text-[var(--dg-text-secondary)]">{stage.tasks.length} {t('pipeline.tasks')}</span>
<Badge variant="secondary" className="text-xs">{stageTypeLabelKeys[stage.type] ? t(stageTypeLabelKeys[stage.type]) : stage.type}</Badge>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); handleMoveStage(idx, -1); }} disabled={idx === 0}><ChevronUp className="w-3.5 h-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); handleMoveStage(idx, 1); }} disabled={idx === stages.length - 1}><ChevronDown className="w-3.5 h-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-6 w-6 text-red-500" onClick={(e) => { e.stopPropagation(); handleDeleteStage(idx); }}><Trash2 className="w-3.5 h-3.5" /></Button>
</div>
{isExpanded && (
<div className="border border-[var(--dg-border)] rounded-b-md p-3 space-y-2">
{stage.tasks.map(tk => (<TaskCard key={tk.id} task={tk} selected={selectedTaskId === tk.id} onSelect={() => setSelectedTaskId(selectedTaskId === tk.id ? null : tk.id)} onDelete={() => handleDeleteTask(tk.id)} />))}
<Button variant="outline" size="sm" className="w-full h-8 text-xs" onClick={() => handleAddTask(idx)}><Plus className="w-3 h-3 mr-1" />{t('pipeline.addTask')}</Button>
</div>
)}
{idx < stages.length - 1 && <div className="flex justify-center py-1"><ChevronRight className="w-4 h-4 rotate-90 text-[var(--dg-text-secondary)] opacity-30" /></div>}
</div>
);
})
)}
</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)} />
</div>
</div>
</TabsContent>
{/* ====== Triggers Tab ====== */}
<TabsContent value="triggers" className="mt-3">
<div className="space-y-4 max-w-lg">
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"><CardContent className="pt-6 space-y-4">
<div className="flex items-center justify-between"><div className="flex items-center gap-2 text-[var(--dg-text-primary)]"><GitBranch className="w-5 h-5 dg-text-blue" /><span className="font-medium">{t('pipeline.gitPushTrigger')}</span></div><Switch checked={triggerMode === 'git_push'} onCheckedChange={v => setTriggerMode(v ? 'git_push' : 'manual')} /></div>
{triggerMode === 'git_push' && <div className="space-y-1"><Label>{t('pipeline.listenBranch')}</Label><Input value={watchBranches} onChange={e => setWatchBranches(e.target.value)} placeholder="master, release/*" className="h-9" /></div>}
</CardContent></Card>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"><CardContent className="pt-6 space-y-4">
<div className="flex items-center justify-between"><div className="flex items-center gap-2 text-[var(--dg-text-primary)]"><Clock className="w-5 h-5 dg-text-blue" /><span className="font-medium">{t('pipeline.cronTrigger')}</span></div><Switch checked={triggerMode === 'cron'} onCheckedChange={v => setTriggerMode(v ? 'cron' : 'manual')} /></div>
{triggerMode === 'cron' && <div className="space-y-1"><Label>{t('pipeline.cronExpr')}</Label><Input value={cronExpr} onChange={e => setCronExpr(e.target.value)} placeholder="0 2 * * *" className="h-9 font-mono" /></div>}
</CardContent></Card>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"><CardContent className="pt-6 space-y-4">
<div className="flex items-center justify-between"><div className="flex items-center gap-2 text-[var(--dg-text-primary)]"><Webhook className="w-5 h-5 dg-text-blue" /><span className="font-medium">{t('pipeline.webhookTrigger')}</span></div><Switch checked={triggerMode === 'webhook'} onCheckedChange={v => setTriggerMode(v ? 'webhook' : 'manual')} /></div>
{triggerMode === 'webhook' && <div className="space-y-1"><Label>Secret Token</Label><Input value={webhookToken} onChange={e => setWebhookToken(e.target.value)} type="password" placeholder={t('pipeline.inputSignKey')} className="h-9" /></div>}
</CardContent></Card>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"><CardContent className="pt-6"><div className="flex items-center gap-2 text-[var(--dg-text-primary)]"><Hand className="w-5 h-5 dg-text-blue" /><span className="font-medium">{t('pipeline.manualTrigger')}</span><Badge variant="secondary" className="text-xs">{t('pipeline.defaultEnabled')}</Badge></div></CardContent></Card>
</div>
</TabsContent>
{/* ====== Variables Tab ====== */}
<TabsContent value="variables" className="mt-3">
<div className="max-w-lg space-y-3">
{variables.map(([k, v], i) => (
<div key={i} className="flex gap-2 items-center">
<Input value={k} onChange={e => { const next = [...variables]; next[i][0] = e.target.value; setVariables(next); }} placeholder="KEY" className="h-9 w-40 font-mono text-sm" />
<span className="text-[var(--dg-text-secondary)]">=</span>
<Input value={v} onChange={e => { const next = [...variables]; next[i][1] = e.target.value; setVariables(next); }} placeholder="VALUE" className="h-9 flex-1" />
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-500" onClick={() => setVariables(variables.filter((_, j) => j !== i))}><Trash2 className="w-3.5 h-3.5" /></Button>
</div>
))}
<Button variant="outline" size="sm" onClick={() => setVariables([...variables, ['', '']])}><Plus className="w-3.5 h-3.5 mr-1" />{t('pipeline.addVar')}</Button>
</div>
</TabsContent>
{/* ====== 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>
)}
</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>
</TabsContent>
</Tabs>
{/* Log Dialog */}
<LogDialog open={logOpen} onClose={() => setLogOpen(false)} run={logRun} logs={logs} />
</PageShell>
);
}