677 lines
40 KiB
TypeScript
677 lines
40 KiB
TypeScript
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>
|
||
);
|
||
}
|