import { useEffect } from "react"; import { type ScheduleNode, type ScheduleRunSummary, } from "../../services/api"; import { useApi } from "../../context/AuthContext"; import Icon from "../../components/common/Icon"; import { useSchedulesStore } from "./state/schedulesStore"; import { formatDuration, formatSize, formatTime, NODE_STATUS_LABELS, RUN_STATUS_LABELS, TRIGGER_TYPE_LABELS, } from "./utils"; export function RunHistory({ runs, nodes, loading, disabled, onRefresh, }: { runs: ScheduleRunSummary[]; nodes: ScheduleNode[]; loading: boolean; disabled: boolean; onRefresh: () => void; }) { const api = useApi(); const expandedRunId = useSchedulesStore((s) => s.expandedRunId); const runDetails = useSchedulesStore((s) => s.runDetails); const detailLoadingId = useSchedulesStore((s) => s.detailLoadingId); const detailErrors = useSchedulesStore((s) => s.detailErrors); const openLogNodeRunIds = useSchedulesStore((s) => s.openLogNodeRunIds); const logStates = useSchedulesStore((s) => s.logStates); const artifactBusyKey = useSchedulesStore((s) => s.artifactBusyKey); const artifactErrors = useSchedulesStore((s) => s.artifactErrors); const setExpandedRunId = useSchedulesStore((s) => s.setExpandedRunId); const setRunDetails = useSchedulesStore((s) => s.setRunDetails); const setDetailLoadingId = useSchedulesStore((s) => s.setDetailLoadingId); const setDetailErrors = useSchedulesStore((s) => s.setDetailErrors); const setOpenLogNodeRunIds = useSchedulesStore((s) => s.setOpenLogNodeRunIds); const setLogStates = useSchedulesStore((s) => s.setLogStates); const setArtifactBusyKey = useSchedulesStore((s) => s.setArtifactBusyKey); const setArtifactErrors = useSchedulesStore((s) => s.setArtifactErrors); const expandedSummary = runs.find((run) => run.run_id === expandedRunId); useEffect(() => { if (expandedRunId && !expandedSummary) { setExpandedRunId(null); setOpenLogNodeRunIds(() => new Set()); } }, [expandedRunId, expandedSummary]); useEffect(() => { const runId = expandedRunId; if (!runId) return undefined; let cancelled = false; setDetailLoadingId(runId); setDetailErrors((current) => { const next = { ...current }; delete next[runId]; return next; }); api.getScheduleRun(runId) .then((detail) => { if (!cancelled) { setRunDetails((current) => ({ ...current, [runId]: detail })); } }) .catch((error: unknown) => { if (!cancelled) { setDetailErrors((current) => ({ ...current, [runId]: error instanceof Error ? error.message : "运行详情加载失败", })); } }) .finally(() => { if (!cancelled) setDetailLoadingId(null); }); return () => { cancelled = true; }; }, [api, expandedRunId, expandedSummary?.state_version]); const toggleRun = (runId: string): void => { setExpandedRunId(expandedRunId === runId ? null : runId); setOpenLogNodeRunIds(() => new Set()); }; const toggleLog = async (runId: string, nodeRunId: string): Promise => { if (openLogNodeRunIds.has(nodeRunId)) { setOpenLogNodeRunIds((current) => { const next = new Set(current); next.delete(nodeRunId); return next; }); return; } setOpenLogNodeRunIds((current) => new Set(current).add(nodeRunId)); if (logStates[nodeRunId]?.content) return; setLogStates((current) => ({ ...current, [nodeRunId]: { loading: true, content: null, fileName: null, error: null, }, })); try { const artifacts = await api.getScheduleNodeRunArtifacts(runId, nodeRunId); if (!artifacts.log) throw new Error("本次节点执行没有可用日志"); const response = await fetch(artifacts.log.url, { credentials: "same-origin", cache: "no-store", }); if (!response.ok) { throw new Error(`日志读取失败(HTTP ${response.status})`); } const content = await response.text(); setLogStates((current) => ({ ...current, [nodeRunId]: { loading: false, content, fileName: artifacts.log?.file_name ?? null, error: null, }, })); } catch (error) { setLogStates((current) => ({ ...current, [nodeRunId]: { loading: false, content: null, fileName: null, error: error instanceof Error ? error.message : "日志读取失败", }, })); } }; const downloadResult = async ( runId: string, nodeRunId: string, ): Promise => { const busyKey = `${nodeRunId}:result`; setArtifactBusyKey(busyKey); setArtifactErrors((current) => { const next = { ...current }; delete next[nodeRunId]; return next; }); try { const artifacts = await api.getScheduleNodeRunArtifacts(runId, nodeRunId); if (!artifacts.result) throw new Error("本次节点执行没有可下载结果"); const link = document.createElement("a"); link.href = artifacts.result.url; link.download = artifacts.result.file_name; document.body.appendChild(link); link.click(); link.remove(); } catch (error) { setArtifactErrors((current) => ({ ...current, [nodeRunId]: error instanceof Error ? error.message : "结果下载失败", })); } finally { setArtifactBusyKey(null); } }; const downloadLog = async ( runId: string, nodeRunId: string, ): Promise => { const busyKey = `${nodeRunId}:log-download`; setArtifactBusyKey(busyKey); setArtifactErrors((current) => { const next = { ...current }; delete next[nodeRunId]; return next; }); try { const artifacts = await api.getScheduleNodeRunArtifacts(runId, nodeRunId); if (!artifacts.log) throw new Error("本次节点执行没有可下载日志"); const response = await fetch(artifacts.log.url, { credentials: "same-origin", cache: "no-store", }); if (!response.ok) { throw new Error(`日志下载失败(HTTP ${response.status})`); } const objectUrl = URL.createObjectURL(await response.blob()); const link = document.createElement("a"); link.href = objectUrl; link.download = artifacts.log.file_name; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(objectUrl); } catch (error) { setArtifactErrors((current) => ({ ...current, [nodeRunId]: error instanceof Error ? error.message : "日志下载失败", })); } finally { setArtifactBusyKey(null); } }; return (
运行记录 {runs.length}
{loading && runs.length === 0 ? (

正在加载运行记录…

) : runs.length === 0 ? (

点击右上角“立即运行”后,这里会显示状态和耗时。

) : ( runs.map((run) => { const expanded = expandedRunId === run.run_id; const detail = runDetails[run.run_id]; return (
{RUN_STATUS_LABELS[run.run_status]} {formatTime(run.queued_at)} · {formatDuration(run.duration_ms)} {run.error_message && {run.error_message}} {run.run_id.slice(-8)}
{expanded && ( <>
{detailLoadingId === run.run_id && !detail ? (

正在加载运行详情…

) : detailErrors[run.run_id] ? (

{detailErrors[run.run_id]}

) : detail ? ( <>
触发方式
{TRIGGER_TYPE_LABELS[detail.trigger_type]}
工作流版本
v{detail.workflow_version}
开始时间
{formatTime(detail.started_at)}
完成时间
{formatTime(detail.finished_at)}
{(detail.error_code || detail.error_message) && (
{detail.error_code && {detail.error_code}} {detail.error_message &&

{detail.error_message}

}
)}
{detail.node_runs.length === 0 ? (

节点尚未开始执行

) : detail.node_runs.map((nodeRun) => { const node = nodes.find((item) => item.node_id === nodeRun.node_id); const logState = logStates[nodeRun.node_run_id]; const logOpen = openLogNodeRunIds.has(nodeRun.node_run_id); return (
{node?.node_name ?? nodeRun.node_id.slice(-8)} {NODE_STATUS_LABELS[nodeRun.node_status]}
第 {nodeRun.attempt_no} 次 {formatDuration(nodeRun.duration_ms)} 退出码 {nodeRun.exit_code ?? "—"}
{nodeRun.message &&

{nodeRun.message}

}
{artifactErrors[nodeRun.node_run_id] && (

{artifactErrors[nodeRun.node_run_id]}

)} {logOpen && (
{logState?.fileName && (
{logState.fileName} {formatSize(new TextEncoder().encode(logState.content ?? "").length)}
)} {logState?.error ? (

{logState.error}

) : (
{logState?.content ?? "正在读取日志…"}
)}
)}
); })}
) : null}
)} ); }) )} ); }