274 lines
10 KiB
TypeScript
274 lines
10 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useParams } from 'react-router-dom';
|
|
import {
|
|
Play, RefreshCw, Trash2, History,
|
|
} from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { toast } from 'sonner';
|
|
import { pipelineApi } from '@/api';
|
|
import { FileBrowserDialog } from './FileBrowserDialog';
|
|
import { PageShell } from '@/components/layout/PageShell';
|
|
import { StatStrip } from '@/components/ui/stat-strip';
|
|
import type { JsonData } from '@/types/common';
|
|
import {
|
|
type PipelineRun, type LogEntry,
|
|
getStageLogSummary,
|
|
} from './pipeline-run-detail/types';
|
|
import { StageProgressBar } from './pipeline-run-detail/StageProgressBar';
|
|
import { RunsTable } from './pipeline-run-detail/RunsTable';
|
|
import { LogConsole } from './pipeline-run-detail/LogConsole';
|
|
|
|
export default function PipelineRunDetail() {
|
|
const { t } = useTranslation();
|
|
const { code: pipelineCode } = useParams<{ code: string }>();
|
|
|
|
const [pipeline, setPipeline] = useState<JsonData>(null);
|
|
const [runs, setRuns] = useState<PipelineRun[]>([]);
|
|
const [selectedRun, setSelectedRun] = useState<PipelineRun | null>(null);
|
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [selectedStage, setSelectedStage] = useState<string | null>(null);
|
|
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);
|
|
|
|
// 加载流水线详情
|
|
const fetchPipeline = useCallback(async () => {
|
|
try {
|
|
const data: JsonData = await pipelineApi.getByCode(pipelineCode);
|
|
setPipeline(data?.data || data);
|
|
} catch { /* silent, pipeline info optional */ }
|
|
}, [pipelineCode]);
|
|
|
|
// 获取执行记录
|
|
const fetchRuns = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data: JsonData = await pipelineApi.getRunsByCode(pipelineCode, pageSize);
|
|
const list = data?.list || [];
|
|
setRuns(list);
|
|
if (!selectedRun && list.length > 0) {
|
|
setSelectedRun(list[0]);
|
|
}
|
|
} catch { toast.error(t('deploy.runDetail.fetchRunsFailed')); }
|
|
finally { setLoading(false); }
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [pipelineCode, pageSize]);
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
useEffect(() => { fetchPipeline(); fetchRuns(); }, []);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
useEffect(() => { fetchRuns(); }, [pageSize]);
|
|
|
|
// 自动刷新
|
|
useEffect(() => {
|
|
const hasRunning = runs.some(r => r.status === 'running');
|
|
if (!hasRunning) return;
|
|
const timer = setInterval(fetchRuns, 5000);
|
|
return () => clearInterval(timer);
|
|
}, [runs, fetchRuns]);
|
|
|
|
// WebSocket 连接(运行中实时日志)
|
|
const connectWS = useCallback((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 */ }
|
|
};
|
|
ws.onclose = () => { wsRef.current = null; };
|
|
ws.onerror = () => { wsRef.current = null; };
|
|
wsRef.current = ws;
|
|
}, [fetchRuns]);
|
|
|
|
// 加载日志
|
|
const fetchLogs = useCallback(async (run: PipelineRun) => {
|
|
try {
|
|
const data: JsonData = await pipelineApi.getRunLogStream(run.id);
|
|
if (data?.logs) {
|
|
const parsed = typeof data.logs === 'string' ? JSON.parse(data.logs) : data.logs;
|
|
setLogs(Array.isArray(parsed) ? parsed : []);
|
|
} else {
|
|
setLogs([]);
|
|
}
|
|
if (run.status === 'running') connectWS(run.id);
|
|
} catch { setLogs([]); }
|
|
}, [connectWS]);
|
|
|
|
useEffect(() => {
|
|
if (selectedRun) {
|
|
fetchLogs(selectedRun);
|
|
setSelectedStage(null); // reset stage filter on run change
|
|
}
|
|
return () => { if (wsRef.current) wsRef.current.close(); };
|
|
}, [selectedRun, fetchLogs]);
|
|
|
|
// 自动滚动到底部
|
|
useEffect(() => {
|
|
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}, [logs]);
|
|
|
|
// 触发新运行
|
|
const triggerRun = async () => {
|
|
try {
|
|
await pipelineApi.triggerByCode(pipelineCode);
|
|
toast.success(t('deploy.toast.triggered'));
|
|
fetchRuns();
|
|
} catch { toast.error(t('deploy.toast.triggerFailed')); }
|
|
};
|
|
|
|
// 停止运行
|
|
const stopRun = async (runId: number) => {
|
|
try {
|
|
await pipelineApi.stopRunByCode(pipelineCode, runId);
|
|
toast.success(t('deploy.toast.stopped'));
|
|
fetchRuns();
|
|
} catch { toast.error(t('deploy.toast.stopFailed')); }
|
|
};
|
|
|
|
// 批量删除
|
|
const batchDelete = async () => {
|
|
if (selectedIds.size === 0) return;
|
|
setDeleting(true);
|
|
try {
|
|
await pipelineApi.batchDeleteRuns(pipelineCode, Array.from(selectedIds));
|
|
toast.success(t('deploy.runDetail.deletedCount', { count: selectedIds.size }));
|
|
setSelectedIds(new Set());
|
|
fetchRuns();
|
|
} catch { toast.error(t('deploy.toast.batchDeleteFailed')); }
|
|
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 filteredLogs = selectedStage ? logs.filter(l => l.stage === selectedStage) : logs;
|
|
const stageSummary = selectedStage ? getStageLogSummary(logs, selectedStage) : '';
|
|
const successCount = runs.filter(r => r.status === 'success').length;
|
|
const runningCount = runs.filter(r => r.status === 'running').length;
|
|
|
|
return (
|
|
<PageShell
|
|
variant="workspace"
|
|
title={pipeline?.name || t('deploy.pipeline.detail')}
|
|
subtitle={selectedRun ? t('deploy.pipeline.release', { number: selectedRun.build_number }) : (pipeline?.code ? `code: ${pipeline.code}` : t('deploy.runDetail.recordsAndLogs'))}
|
|
icon={<History className="h-5 w-5" />}
|
|
contentClassName="space-y-4 h-full flex flex-col"
|
|
metrics={(
|
|
<StatStrip
|
|
items={[
|
|
{ label: t('deploy.runDetail.execRecords'), value: runs.length, color: 'blue' },
|
|
{ label: t('deploy.status.success'), value: successCount, color: 'emerald' },
|
|
{ label: t('deploy.runDetail.executing'), value: runningCount, color: 'amber' },
|
|
]}
|
|
/>
|
|
)}
|
|
actions={(
|
|
<>
|
|
{pipeline?.environment && (
|
|
<Badge className="text-xs" style={{
|
|
backgroundColor: pipeline.environment.color + '20',
|
|
color: pipeline.environment.color,
|
|
}}>
|
|
{pipeline.environment.display_name}
|
|
</Badge>
|
|
)}
|
|
{selectedIds.size > 0 && (
|
|
<Button variant="destructive" size="sm" onClick={batchDelete} disabled={deleting}>
|
|
<Trash2 className="w-4 h-4 mr-1" />{t('common.delete')} ({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)] dg-input"
|
|
>
|
|
<option value={10}>10{t('deploy.runDetail.perPage')}</option>
|
|
<option value={20}>20{t('deploy.runDetail.perPage')}</option>
|
|
<option value={30}>30{t('deploy.runDetail.perPage')}</option>
|
|
</select>
|
|
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={fetchRuns}>
|
|
<RefreshCw className="w-4 h-4 mr-1" />{t('common.refresh')}
|
|
</Button>
|
|
<Button size="sm" className="dg-btn-primary" onClick={triggerRun}>
|
|
<Play className="w-4 h-4 mr-1" />{t('deploy.pipeline.runNow')}
|
|
</Button>
|
|
</>
|
|
)}
|
|
>
|
|
{/* ═══════════ 阶段进度条 (交互式) ═══════════ */}
|
|
<StageProgressBar
|
|
selectedRun={selectedRun}
|
|
selectedStage={selectedStage}
|
|
onSelectStage={setSelectedStage}
|
|
/>
|
|
|
|
{/* ═══════════ 主内容区: 执行记录表 + 日志 ═══════════ */}
|
|
<div className="flex-1 flex flex-col min-h-0">
|
|
<RunsTable
|
|
runs={runs}
|
|
loading={loading}
|
|
selectedRun={selectedRun}
|
|
selectedIds={selectedIds}
|
|
onSelectRun={setSelectedRun}
|
|
onToggleSelect={toggleSelect}
|
|
onToggleAll={toggleAll}
|
|
onStopRun={stopRun}
|
|
onBrowse={(run) => { setBrowserRun({ id: run.id, buildNumber: run.build_number }); setBrowserOpen(true); }}
|
|
/>
|
|
|
|
{/* ═══════════ 阶段日志 Console ═══════════ */}
|
|
{selectedRun && (
|
|
<LogConsole
|
|
selectedRun={selectedRun}
|
|
pipeline={pipeline}
|
|
pipelineCode={pipelineCode}
|
|
selectedStage={selectedStage}
|
|
filteredLogs={filteredLogs}
|
|
stageSummary={stageSummary}
|
|
logEndRef={logEndRef}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* ═══════════ 工作区文件浏览器 ═══════════ */}
|
|
<FileBrowserDialog
|
|
open={browserOpen}
|
|
onOpenChange={setBrowserOpen}
|
|
runId={browserRun?.id ?? null}
|
|
buildNumber={browserRun?.buildNumber ?? 0}
|
|
/>
|
|
</PageShell>
|
|
);
|
|
}
|