Files
model-platform/frontend/app/features/schedules/RunHistory.tsx
T
tao.chen cc8ac220a5 perf(schedule): 减少重渲染与冗余动画
- useCanvasNodeDrag.moveDrag 改 ref 直改 DOM,松手才写 store,避免拖拽期间反复重渲染 SchedulePage
- runs 轮询改 setTimeout 链式 + 移除 runs 依赖,避免每 1.5s 重建 interval
- SchedulePage 拆 PositionDraftBadge / RunNowButton,删除冗余的 runs/runsLoading/positionDraftCount 订阅
- 拆 ScheduleEdge 组件 + React.memo + useMemo,切换 edge 选中时不再重算所有 edgePath
- 删除 schedule.css 中引用未定义 keyframes 的 modal-in/spin 死动画
- 删除 .schedule-edge-line transition、.schedule-run-result-backdrop backdrop-filter;补 .artifact-card transition

Refs: kaneo #19-#24
2026-08-20 11:27:45 +08:00

413 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect } from "react";
import {
type ScheduleNode,
} 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({
nodes,
disabled,
onRefresh,
}: {
nodes: ScheduleNode[];
disabled: boolean;
onRefresh: () => void;
}) {
const api = useApi();
const runs = useSchedulesStore((s) => s.runs);
const loading = useSchedulesStore((s) => s.runsLoading);
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<string>());
}
}, [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<string>());
};
const toggleLog = async (runId: string, nodeRunId: string): Promise<void> => {
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<void> => {
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<void> => {
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 (
<section className="schedule-run-history">
<header>
<div>
<strong>运行记录</strong>
<span>{runs.length}</span>
</div>
<button
type="button"
aria-label="刷新运行记录"
disabled={disabled || loading}
onClick={onRefresh}
>
<Icon name="refresh" size={13} />
</button>
</header>
<div className="schedule-run-list">
{loading && runs.length === 0 ? (
<p>正在加载运行记录…</p>
) : runs.length === 0 ? (
<p>点击右上角“立即运行”后,这里会显示状态和耗时。</p>
) : (
runs.map((run) => {
const expanded = expandedRunId === run.run_id;
const detail = runDetails[run.run_id];
return (
<article
className={`schedule-run-card${expanded ? " is-expanded" : ""}`}
key={run.run_id}
>
<div
className="schedule-run-summary"
>
<span className={`run-status-dot is-${run.run_status}`} />
<span className="schedule-run-summary-copy">
<strong>{RUN_STATUS_LABELS[run.run_status]}</strong>
<small>
{formatTime(run.queued_at)} · {formatDuration(run.duration_ms)}
</small>
{run.error_message && <span>{run.error_message}</span>}
</span>
<code title={run.run_id}>{run.run_id.slice(-8)}</code>
<button
className="schedule-run-result-button"
type="button"
aria-haspopup="dialog"
onClick={() => toggleRun(run.run_id)}
>
查看结果
</button>
</div>
{expanded && (
<>
<button
className="schedule-run-result-backdrop"
type="button"
aria-label="关闭运行结果"
onClick={() => toggleRun(run.run_id)}
/>
<section
className="schedule-run-result-modal"
role="dialog"
aria-modal="true"
aria-label="调度运行结果"
>
<header className="schedule-run-result-modal__header">
<div>
<span>调度运行结果</span>
<strong>{RUN_STATUS_LABELS[run.run_status]}</strong>
<code>{run.run_id}</code>
</div>
<button
className="icon-button"
type="button"
aria-label="关闭运行结果"
onClick={() => toggleRun(run.run_id)}
>
<Icon name="close" size={16} />
</button>
</header>
<div className="schedule-run-detail">
{detailLoadingId === run.run_id && !detail ? (
<p className="run-detail-message">正在加载运行详情…</p>
) : detailErrors[run.run_id] ? (
<p className="run-detail-message is-error">
{detailErrors[run.run_id]}
</p>
) : detail ? (
<>
<dl className="schedule-run-meta">
<div><dt>触发方式</dt><dd>{TRIGGER_TYPE_LABELS[detail.trigger_type]}</dd></div>
<div><dt>工作流版本</dt><dd>v{detail.workflow_version}</dd></div>
<div><dt>开始时间</dt><dd>{formatTime(detail.started_at)}</dd></div>
<div><dt>完成时间</dt><dd>{formatTime(detail.finished_at)}</dd></div>
</dl>
{(detail.error_code || detail.error_message) && (
<div className="schedule-run-error">
{detail.error_code && <code>{detail.error_code}</code>}
{detail.error_message && <p>{detail.error_message}</p>}
</div>
)}
<div className="schedule-node-run-list">
{detail.node_runs.length === 0 ? (
<p className="run-detail-message">节点尚未开始执行</p>
) : 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 (
<article className="schedule-node-run" key={nodeRun.node_run_id}>
<header>
<span className={`run-status-dot is-${nodeRun.node_status}`} />
<strong>{node?.node_name ?? nodeRun.node_id.slice(-8)}</strong>
<span>{NODE_STATUS_LABELS[nodeRun.node_status]}</span>
</header>
<div className="schedule-node-run-meta">
<span> {nodeRun.attempt_no} </span>
<span>{formatDuration(nodeRun.duration_ms)}</span>
<span>退出码 {nodeRun.exit_code ?? "—"}</span>
</div>
{nodeRun.message && <p>{nodeRun.message}</p>}
<div className="schedule-node-run-actions">
<button
type="button"
disabled={!nodeRun.logs_object_id || logState?.loading}
onClick={() => void toggleLog(run.run_id, nodeRun.node_run_id)}
>
{logState?.loading ? "读取中…" : logOpen ? "收起日志" : "查看日志"}
</button>
<button
type="button"
disabled={
!nodeRun.logs_object_id
|| artifactBusyKey === `${nodeRun.node_run_id}:log-download`
}
onClick={() => void downloadLog(run.run_id, nodeRun.node_run_id)}
>
{artifactBusyKey === `${nodeRun.node_run_id}:log-download`
? "准备中…"
: "下载日志"}
</button>
<button
type="button"
disabled={
!nodeRun.result_object_id
|| artifactBusyKey === `${nodeRun.node_run_id}:result`
}
onClick={() => void downloadResult(run.run_id, nodeRun.node_run_id)}
>
{artifactBusyKey === `${nodeRun.node_run_id}:result`
? "准备中…"
: "下载结果"}
</button>
</div>
{artifactErrors[nodeRun.node_run_id] && (
<p className="schedule-artifact-error">
{artifactErrors[nodeRun.node_run_id]}
</p>
)}
{logOpen && (
<div className="schedule-node-log">
{logState?.fileName && (
<header>
<span>{logState.fileName}</span>
<small>
{formatSize(new TextEncoder().encode(logState.content ?? "").length)}
</small>
</header>
)}
{logState?.error ? (
<p>{logState.error}</p>
) : (
<pre>{logState?.content ?? "正在读取日志…"}</pre>
)}
</div>
)}
</article>
);
})}
</div>
</>
) : null}
</div>
</section>
</>
)}
</article>
);
})
)}
</div>
</section>
);
}