Files
aiops/frontend/src/components/deploy/PipelineRunDetail.tsx
T

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>
);
}