feat: 发布权限 RBAC + PipelineEditor 修复 + 菜单优化

- 新增 DeployRBAC 页面: Jenkins-style Item Role 权限管理
  - 正则匹配流水线 code,实时命中预览
  - 内置用户分配面板,无需跳转系统管理
  - 支持创建/编辑/删除/启用禁用 Item Role
- 修复 PipelineEditor 成员管理 http 双解包 bug
- 修复 PipelineManager 前端 code 路由迁移(delete/trigger/stop/loadRuns)
- 新增批量删除流水线运行记录 API
- Sidebar: 应用发布下新增「发布权限」, 仓库认证→发布配置, 移除用户组
- 恢复系统管理菜单(不受发布权限改动影响)
- 后端 ItemRole CRUD handler + 正则匹配中间件已就绪
This commit is contained in:
attacker committed 2026-06-21 05:26:01 +00:00
1 parent 0fea0ccc20
commit 24f955832e
40 files changed
+7530 -1032

No files matched your search

@@ -0,0 +1,473 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import React from 'react';
import { useParams } from 'react-router-dom';
import { Play, Square, RefreshCw, Clock, User, AlertCircle, CheckCircle2, Loader2, XCircle, History, FolderOpen, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { toast } from 'sonner';
import http from '@/services/http';
import { FileBrowserDialog } from './FileBrowserDialog';
interface PipelineRun {
id: number;
pipeline_id: number;
build_number: number;
status: string;
branch: string;
commit_id: string;
commit_msg: string;
author: string;
triggered_by: string;
image_tag: string;
duration: number;
started_at: string;
finished_at: string;
created_at: string;
}
interface LogEntry {
time: string;
level: string;
stage: string;
message: string;
}
const STATUS_BADGES: Record<string, string> = {
success: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
failed: 'bg-red-500/20 text-red-400 border-red-500/30',
running: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
pending: 'bg-slate-500/20 text-slate-400 border-slate-500/30',
cancelled: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
};
const STATUS_ICONS: Record<string, any> = {
success: CheckCircle2,
failed: XCircle,
running: Loader2,
pending: Clock,
cancelled: AlertCircle,
};
const LEVEL_COLORS: Record<string, string> = {
info: 'text-[var(--dg-text-secondary)]',
success: 'text-emerald-400',
error: 'text-red-400',
warn: 'text-amber-400',
};
function formatDuration(seconds: number): string {
if (!seconds) return '-';
if (seconds < 60) return `${seconds}秒`;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}分${s}秒`;
}
function formatTime(ts: string): string {
if (!ts) return '-';
const d = new Date(ts);
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
export default function PipelineRunDetail() {
const { code: pipelineCode } = useParams<{ code: string }>();
const [runs, setRuns] = useState<PipelineRun[]>([]);
const [selectedRun, setSelectedRun] = useState<PipelineRun | null>(null);
const [logs, setLogs] = useState<LogEntry[]>([]);
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState('records');
const [pageSize, setPageSize] = useState(10);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [deleting, setDeleting] = useState(false);
const logEndRef = useRef<HTMLDivElement>(null);
const wsRef = useRef<WebSocket | null>(null);
// 文件浏览器 dialog 状态
const [browserOpen, setBrowserOpen] = useState(false);
const [browserRun, setBrowserRun] = useState<{ id: number; buildNumber: number } | null>(null);
// 获取执行记录 (http.get 已自动解包 json.data → {list, total})
const fetchRuns = useCallback(async () => {
setLoading(true);
try {
const data: any = await http.get(`/pipelines/${pipelineCode}/runs?page_size=${pageSize}`);
const list = data?.list || [];
setRuns(list);
// 自动选中最新运行
if (!selectedRun && list.length > 0) {
setSelectedRun(list[0]);
}
} catch { toast.error('获取执行记录失败'); }
finally { setLoading(false); }
}, [pipelineCode, pageSize]);
useEffect(() => { fetchRuns(); }, []);
useEffect(() => { fetchRuns(); }, [pageSize]);
// 自动刷新(有运行中的记录时每5秒刷新)
useEffect(() => {
const hasRunning = runs.some(r => r.status === 'running');
if (!hasRunning) return;
const t = setInterval(fetchRuns, 5000);
return () => clearInterval(t);
}, [runs, fetchRuns]);
// 加载日志 (http.get 已自动解包 json.data)
const fetchLogs = useCallback(async (run: PipelineRun) => {
try {
const data: any = await http.get(`/pipelines/runs/${run.id}/log-stream`);
if (data?.logs) {
const parsed = typeof data.logs === 'string' ? JSON.parse(data.logs) : data.logs;
setLogs(Array.isArray(parsed) ? parsed : []);
} else {
setLogs([]);
}
// 如果运行中,连接 WebSocket
if (run.status === 'running') {
connectWS(run.id);
}
} catch {
setLogs([]);
}
}, []);
// WebSocket 连接
const connectWS = (runId: number) => {
if (wsRef.current) {
wsRef.current.close();
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = localStorage.getItem('token');
const ws = new WebSocket(`${protocol}//${window.location.host}/ws/pipeline/${runId}/log?token=${token}`);
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'init' && data.logs) {
const parsed = typeof data.logs === 'string' ? JSON.parse(data.logs) : data.logs;
setLogs(Array.isArray(parsed) ? parsed : []);
} else if (data.type === 'done') {
ws.close();
fetchRuns();
} else if (data.time && data.level) {
setLogs(prev => [...prev, data]);
}
} catch { /* binary frame, ignore */ }
};
ws.onclose = () => { wsRef.current = null; };
ws.onerror = () => { wsRef.current = null; };
wsRef.current = ws;
};
useEffect(() => {
if (selectedRun) {
fetchLogs(selectedRun);
}
return () => {
if (wsRef.current) wsRef.current.close();
};
}, [selectedRun, fetchLogs]);
// 自动滚动到底部
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [logs]);
// 触发流水线
const triggerRun = async () => {
try {
await http.post(`/pipelines/${pipelineCode}/trigger`, {});
toast.success('已触发');
fetchRuns();
} catch { toast.error('触发失败'); }
};
// 停止流水线
const stopRun = async (runId: number) => {
try {
await http.post(`/pipelines/${pipelineCode}/runs/${runId}/stop`, {});
toast.success('已停止');
fetchRuns();
} catch { toast.error('停止失败'); }
};
// 批量删除
const batchDelete = async () => {
if (selectedIds.size === 0) return;
setDeleting(true);
try {
await http.post(`/pipelines/${pipelineCode}/runs/batch-delete`, { ids: Array.from(selectedIds) });
toast.success(`已删除 ${selectedIds.size} 条记录`);
setSelectedIds(new Set());
fetchRuns();
} catch { toast.error('批量删除失败'); }
finally { setDeleting(false); }
};
const toggleSelect = (id: number) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const toggleAll = () => {
if (selectedIds.size === runs.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(runs.map(r => r.id)));
}
};
const triggerLabel = (run: PipelineRun) => {
if (run.triggered_by === 'system') return 'Cron';
if (run.triggered_by === 'git') return 'Git Push';
return run.triggered_by || 'Manual';
};
return (
<div className="space-y-4 h-full flex flex-col">
{/* 头部 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)]">
<History className="w-5 h-5 inline mr-2" />
执行记录
</h2>
<p className="text-sm text-[var(--dg-text-muted)] mt-0.5">流水线 #{pipelineCode}</p>
</div>
<div className="flex gap-2 items-center">
{selectedIds.size > 0 && (
<Button variant="destructive" size="sm" onClick={batchDelete} disabled={deleting}>
<Trash2 className="w-4 h-4 mr-1" />删除 ({selectedIds.size})
</Button>
)}
<select
value={pageSize}
onChange={e => { setPageSize(Number(e.target.value)); }}
className="h-8 px-2 text-xs rounded border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-[var(--dg-text-primary)]"
>
<option value={10}>10条/页</option>
<option value={20}>20条/页</option>
<option value={30}>30条/页</option>
</select>
<Button variant="outline" size="sm" onClick={fetchRuns}>
<RefreshCw className="w-4 h-4 mr-1" />刷新
</Button>
<Button size="sm" onClick={triggerRun}>
<Play className="w-4 h-4 mr-1" />立即执行
</Button>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col">
<TabsList>
<TabsTrigger value="records">执行记录 ({runs.length})</TabsTrigger>
<TabsTrigger value="log" disabled={!selectedRun}>执行日志</TabsTrigger>
</TabsList>
{/* 执行记录列表 */}
<TabsContent value="records" className="flex-1 mt-3">
{loading && runs.length === 0 ? (
<div className="text-center py-12"><Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-[var(--dg-text-muted)]" />加载中...</div>
) : runs.length === 0 ? (
<div className="text-center py-12 border border-dashed border-[var(--dg-border)] rounded-lg">
<History className="w-10 h-10 mx-auto mb-3 text-[var(--dg-text-muted)]" />
<p className="text-[var(--dg-text-secondary)]">暂无执行记录,点击"立即执行"触发构建</p>
</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-2 py-2.5 w-10">
<input type="checkbox" checked={selectedIds.size === runs.length && runs.length > 0} onChange={toggleAll} className="rounded" />
</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] w-16">构建</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] w-20">状态</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">触发方式</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">用户</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">耗时</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">时间</th>
<th className="px-3 py-2.5 text-right font-medium text-[var(--dg-text-secondary)] w-32">操作</th>
</tr>
</thead>
<tbody>
{runs.map(run => {
const StatusIcon = STATUS_ICONS[run.status] || Clock;
return (
<tr key={run.id}
className={`border-b border-[var(--dg-border)] cursor-pointer transition-colors
${selectedRun?.id === run.id ? 'bg-[var(--dg-blue)]/10' : 'hover:bg-[var(--dg-bg-tertiary)]/50'}`}
onClick={() => { setSelectedRun(run); setActiveTab('log'); }}>
<td className="px-2 py-2.5" onClick={e => e.stopPropagation()}>
<input type="checkbox" checked={selectedIds.has(run.id)} onChange={() => toggleSelect(run.id)} className="rounded" />
</td>
<td className="px-3 py-2.5 font-mono font-semibold text-[var(--dg-text-primary)]">
#{run.build_number}
</td>
<td className="px-3 py-2.5">
<Badge className={`text-xs ${STATUS_BADGES[run.status] || ''} flex items-center gap-1 w-fit`}>
<StatusIcon className={`w-3 h-3 ${run.status === 'running' ? 'animate-spin' : ''}`} />
{run.status === 'success' ? '成功' : run.status === 'failed' ? '失败' : run.status === 'running' ? '执行中' : run.status === 'cancelled' ? '已取消' : '待执行'}
</Badge>
</td>
<td className="px-3 py-2.5">
<Badge variant="outline" className="text-xs">{triggerLabel(run)}</Badge>
</td>
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)] flex items-center gap-1">
<User className="w-3 h-3" />
{run.triggered_by || 'admin'}
</td>
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)] font-mono text-xs">
{run.status === 'running' ? (
<span className="flex items-center gap-1 text-blue-400">
<Loader2 className="w-3 h-3 animate-spin" />
执行中...
</span>
) : formatDuration(run.duration)}
</td>
<td className="px-3 py-2.5 text-[var(--dg-text-muted)] text-xs hidden md:table-cell">
{formatTime(run.created_at)}
</td>
<td className="px-3 py-2.5 text-right" onClick={e => e.stopPropagation()}>
<div className="flex justify-end gap-1">
<Button variant="ghost" size="sm" className="h-7 text-xs"
onClick={() => { setSelectedRun(run); setActiveTab('log'); }}>
执行日志
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs"
onClick={() => { setBrowserRun({ id: run.id, buildNumber: run.build_number }); setBrowserOpen(true); }}>
<FolderOpen className="w-3.5 h-3.5 mr-1" />
浏览
</Button>
{run.status === 'running' && (
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500"
onClick={() => stopRun(run.id)}>
<Square className="w-3.5 h-3.5" />
</Button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</TabsContent>
{/* 执行日志 - Console 输出 */}
<TabsContent value="log" className="flex-1 mt-3">
{!selectedRun ? (
<div className="text-center py-12 text-[var(--dg-text-muted)]">请先选择一条执行记录</div>
) : (
<Card className="flex-1 flex flex-col border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
<CardHeader className="py-3 px-4 flex flex-row items-center justify-between border-b border-[var(--dg-border)]">
<div className="flex items-center gap-3">
<CardTitle className="text-sm font-mono">
aiops-backend #{selectedRun.build_number}
</CardTitle>
{selectedRun.status === 'running' && (
<Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30 text-xs">
<Loader2 className="w-3 h-3 animate-spin mr-1" />实时
</Badge>
)}
</div>
<div className="flex items-center gap-2 text-xs text-[var(--dg-text-muted)]">
<span>触发方式: {triggerLabel(selectedRun)} ({selectedRun.triggered_by || 'admin'})</span>
</div>
</CardHeader>
<CardContent className="flex-1 p-0 overflow-hidden">
<div className="h-full overflow-y-auto bg-[#0d1117] font-mono text-xs leading-5 p-4" style={{ minHeight: 400 }}>
{logs.length === 0 && selectedRun.status !== 'running' ? (
<div className="text-center py-12 text-slate-500">暂无日志输出</div>
) : (
<>
{(() => {
let lastStage = '';
const stageStyles: Record<string, string> = {
checkout: 'border-indigo-500 bg-indigo-500/10',
build: 'border-amber-500 bg-amber-500/10',
test: 'border-emerald-500 bg-emerald-500/10',
image: 'border-cyan-500 bg-cyan-500/10',
deploy: 'border-red-500 bg-red-500/10',
verify: 'border-blue-500 bg-blue-500/10',
};
const stageIcons: Record<string, string> = {
checkout: '⬇', build: '⚙', test: '✓',
image: '📦', deploy: '🚀', verify: '🛡',
};
return logs.map((entry, i) => {
const showStage = entry.stage && entry.stage !== lastStage;
if (showStage) lastStage = entry.stage || '';
const isError = entry.level === 'error';
const isWarn = entry.level === 'warn';
const isSuccess = entry.level === 'success';
const accent = isError ? 'border-l-2 border-red-500 bg-red-500/10' :
isWarn ? 'border-l-2 border-amber-500 bg-amber-500/10' :
isSuccess ? 'border-l-2 border-emerald-500 bg-emerald-500/5' : '';
const msgColor = isError ? 'text-red-200' :
isWarn ? 'text-amber-200' :
isSuccess ? 'text-emerald-200' : 'text-slate-300';
const levelBadge = isError
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-red-600/40 text-red-200">ERR</span>
: isWarn
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-amber-600/40 text-amber-200">WARN</span>
: isSuccess
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-emerald-600/30 text-emerald-200"> OK </span>
: <span className="shrink-0 px-1.5 py-px rounded text-[10px] bg-slate-600/30 text-slate-400">INFO</span>;
return (
<React.Fragment key={i}>
{showStage && (
<div className={`flex items-center gap-2 my-2 py-1 px-2 border-l-4 rounded-r ${stageStyles[entry.stage || ''] || 'border-slate-500 bg-slate-500/10'}`}>
<span className="text-xs">{stageIcons[entry.stage || ''] || '▸'}</span>
<span className="text-[11px] font-semibold tracking-wider text-slate-200">{entry.stage}</span>
<span className="flex-1 border-t border-slate-700/50"></span>
</div>
)}
<div className={`flex items-start gap-2 px-2 py-0.5 -mx-1 rounded-sm ${accent} ${!isError && !isWarn && !isSuccess ? 'hover:bg-white/[0.03]' : ''}`}>
<span className="text-slate-600 shrink-0 w-14 text-right text-[10px] pt-px">{entry.time}</span>
{levelBadge}
<span className={`break-all ${msgColor}`}>{entry.message}</span>
</div>
</React.Fragment>
);
});
})()}
{selectedRun.status === 'running' && (
<div className="flex items-center gap-2 mt-2 py-1.5 px-3 bg-blue-500/10 border-l-4 border-blue-500 rounded-r">
<Loader2 className="w-3 h-3 animate-spin text-blue-400 shrink-0" />
<span className="text-[11px] text-blue-300 font-medium">流水线执行中,实时输出...</span>
<span className="flex gap-1 ml-1">
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{animationDelay:'0ms'}}></span>
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{animationDelay:'150ms'}}></span>
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{animationDelay:'300ms'}}></span>
</span>
</div>
)}
<div ref={logEndRef} />
</>
)}
</div>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
{/* 工作区文件浏览器 */}
<FileBrowserDialog
open={browserOpen}
onOpenChange={setBrowserOpen}
runId={browserRun?.id ?? null}
buildNumber={browserRun?.buildNumber ?? 0}
/>
</div>
);
}