- 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
413 lines
16 KiB
TypeScript
413 lines
16 KiB
TypeScript
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>
|
||
);
|
||
}
|