Files
model-platform/frontend/app/features/schedules/RunHistory.tsx
T

483 lines
23 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 { RefreshCw, X } from "lucide-react";
import { useSchedulesStore } from "./state/useSchedulesStore";
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="flex min-h-0 flex-col border-t border-[#e0e7ee] bg-[#fbfcfe]">
<header className="flex min-h-[44px] items-center justify-between border-b border-[#e8edf2] bg-white px-3">
<div className="flex items-center gap-[7px]">
<strong className="text-[12px] text-[#2b4056]">运行记录</strong>
<span className="inline-grid h-[18px] min-w-[18px] place-items-center rounded-[9px] bg-[#edf2f7] text-[9px] text-[#71849a]">
{runs.length}
</span>
</div>
<button
className="grid h-[27px] w-[27px] cursor-pointer place-items-center rounded-[5px] border border-[#dce5ee] bg-white text-[#66809a] disabled:cursor-not-allowed disabled:opacity-[0.45]"
type="button"
aria-label="刷新运行记录"
disabled={disabled || loading}
onClick={onRefresh}
>
<RefreshCw size={13} />
</button>
</header>
<div className="flex min-h-0 flex-1 flex-col gap-[7px] overflow-auto p-[9px]">
{loading && runs.length === 0 ? (
<p className="my-auto p-3 text-center text-[10px] leading-[1.6] text-[#929fac]">
正在加载运行记录…
</p>
) : runs.length === 0 ? (
<p className="my-auto p-3 text-center text-[10px] leading-[1.6] text-[#929fac]">
点击右上角“立即运行”后,这里会显示状态和耗时。
</p>
) : (
runs.map((run) => {
const expanded = expandedRunId === run.run_id;
const detail = runDetails[run.run_id];
return (
<article
data-state={expanded ? "expanded" : "collapsed"}
className="flex-none overflow-hidden rounded-[6px] border border-[#e1e8ef] bg-white data-[state=expanded]:border-[#b9d6f1] data-[state=expanded]:shadow-[0_3px_10px_rgb(38_117_185_/_8%)]"
key={run.run_id}
>
<div
className="grid w-full items-center gap-[7px] border-0 bg-white p-2 text-left text-inherit [grid-template-columns:8px_minmax(0,1fr)_auto_auto] hover:bg-[#f7fbff]"
>
<span
data-state={run.run_status}
className="mt-[3px] size-[7px] rounded-full bg-slate-400 data-[state=queued]:bg-warning data-[state=running]:bg-blue-500 data-[state=succeeded]:bg-emerald-500 data-[state=failed]:bg-red-500 data-[state=cancelled]:bg-red-500 data-[state=timed_out]:bg-red-500 data-[state=skipped]:bg-slate-400"
/>
<span className="flex min-w-0 flex-col gap-[2px]">
<strong className="text-[10px] text-[#425970]">
{RUN_STATUS_LABELS[run.run_status]}
</strong>
<small className="text-[8px] text-ink-subtle">
{formatTime(run.queued_at)} · {formatDuration(run.duration_ms)}
</small>
{run.error_message && (
<span className="truncate text-[8px] leading-[1.4] text-[#b65353]">
{run.error_message}
</span>
)}
</span>
<code className="font-mono text-[8px] text-[#8b9bad]" title={run.run_id}>
{run.run_id.slice(-8)}
</code>
<button
className="min-h-[27px] cursor-pointer whitespace-nowrap rounded-[5px] border border-[#bcd4e9] bg-[#f5faff] px-[9px] text-[9px] text-[#2374ba] [font-weight:650] hover:border-[#7eafe0] hover:bg-[#eaf5ff]"
type="button"
aria-haspopup="dialog"
onClick={() => toggleRun(run.run_id)}
>
查看结果
</button>
</div>
{expanded && (
<>
<button
className="fixed inset-0 z-[40] cursor-default border-0 bg-[rgb(10_27_45_/_46%)]"
type="button"
aria-label="关闭运行结果"
onClick={() => toggleRun(run.run_id)}
/>
<section
className="fixed left-1/2 top-1/2 z-[41] grid max-h-[min(760px,calc(100vh-64px))] w-[min(780px,calc(100vw-48px))] -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-[12px] border border-[#d8e2eb] bg-white shadow-[0_24px_80px_rgb(8_27_48_/_30%)] [grid-template-rows:auto_minmax(0,1fr)]"
role="dialog"
aria-modal="true"
aria-label="调度运行结果"
>
<header className="flex items-center justify-between gap-[18px] border-b border-[#e3eaf0] bg-white p-[17px_20px]">
<div className="grid min-w-0 items-center gap-[5px_10px] [grid-template-columns:auto_auto]">
<span className="text-[17px] font-bold text-[#1f344a]">调度运行结果</span>
<strong className="justify-self-start rounded-[11px] bg-success-soft px-2 py-[3px] text-[10px] text-[#17805a]">
{RUN_STATUS_LABELS[run.run_status]}
</strong>
<code className="col-span-2 truncate font-mono text-[10px] text-[#8291a1]">
{run.run_id}
</code>
</div>
<button
className="grid h-[34px] w-[34px] cursor-pointer place-items-center rounded-[7px] border border-[#dfe6ed] bg-white hover:border-[#b9c9da] hover:bg-[#f7faff]"
type="button"
aria-label="关闭运行结果"
onClick={() => toggleRun(run.run_id)}
>
<X size={16} />
</button>
</header>
<div className="min-h-0 overflow-auto bg-[#f8fbfe] p-[18px]">
{detailLoadingId === run.run_id && !detail ? (
<p className="m-0 p-[8px_4px] text-center text-[9px] leading-[1.5] text-[#8796a6]">
正在加载运行详情…
</p>
) : detailErrors[run.run_id] ? (
<p
data-state="error"
className="m-0 p-[8px_4px] text-center text-[9px] leading-[1.5] text-[#8796a6] data-[state=error]:text-[#bf5656]"
>
{detailErrors[run.run_id]}
</p>
) : detail ? (
<>
<dl className="mb-[14px] grid gap-[10px_18px] [grid-template-columns:repeat(4,minmax(0,1fr))]">
<div className="min-w-0">
<dt className="mb-1 text-[10px] text-[#95a2af]">触发方式</dt>
<dd className="m-0 truncate text-[12px] text-[#41566c]">
{TRIGGER_TYPE_LABELS[detail.trigger_type]}
</dd>
</div>
<div className="min-w-0">
<dt className="mb-1 text-[10px] text-[#95a2af]">工作流版本</dt>
<dd className="m-0 truncate text-[12px] text-[#41566c]">
v{detail.workflow_version}
</dd>
</div>
<div className="min-w-0">
<dt className="mb-1 text-[10px] text-[#95a2af]">开始时间</dt>
<dd className="m-0 truncate text-[12px] text-[#41566c]">
{formatTime(detail.started_at)}
</dd>
</div>
<div className="min-w-0">
<dt className="mb-1 text-[10px] text-[#95a2af]">完成时间</dt>
<dd className="m-0 truncate text-[12px] text-[#41566c]">
{formatTime(detail.finished_at)}
</dd>
</div>
</dl>
{(detail.error_code || detail.error_message) && (
<div className="mb-2 rounded-[5px] border border-[#f0cece] bg-danger-soft p-[7px] text-danger-strong">
{detail.error_code && <code className="text-[8px] font-bold">{detail.error_code}</code>}
{detail.error_message && (
<p className="mt-[3px] text-[8px] leading-[1.45]">{detail.error_message}</p>
)}
</div>
)}
<div className="flex flex-col gap-2.5">
{detail.node_runs.length === 0 ? (
<p className="m-0 p-[8px_4px] text-center text-[9px] leading-[1.5] text-[#8796a6]">
节点尚未开始执行
</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="rounded-[7px] border border-[#dde7f0] bg-white p-3" key={nodeRun.node_run_id}>
<header className="grid items-start gap-1.5 [grid-template-columns:8px_minmax(0,1fr)_auto]">
<span
data-state={nodeRun.node_status}
className="mt-[3px] size-[7px] rounded-full bg-slate-400 data-[state=queued]:bg-warning data-[state=running]:bg-blue-500 data-[state=succeeded]:bg-emerald-500 data-[state=failed]:bg-red-500 data-[state=cancelled]:bg-red-500 data-[state=timed_out]:bg-red-500 data-[state=skipped]:bg-slate-400"
/>
<strong className="truncate text-[13px] text-[#40566c]">
{node?.node_name ?? nodeRun.node_id.slice(-8)}
</strong>
<span className="text-[10px] text-[#71869a]">
{NODE_STATUS_LABELS[nodeRun.node_status]}
</span>
</header>
<div className="ml-[14px] mt-[5px] flex flex-wrap gap-[3px_8px] text-[10px] text-[#8a98a7]">
<span> {nodeRun.attempt_no} </span>
<span>{formatDuration(nodeRun.duration_ms)}</span>
<span>退出码 {nodeRun.exit_code ?? "—"}</span>
</div>
{nodeRun.message && (
<p className="ml-[14px] mt-[5px] text-[10px] leading-[1.45] text-[#6c7e90]">
{nodeRun.message}
</p>
)}
<div className="ml-[14px] mt-[7px] flex gap-1.5">
<button
className="cursor-pointer rounded-[4px] border border-[#cbddeb] bg-[#f5faff] p-[6px_10px] text-[10px] text-[#2878bd] disabled:cursor-not-allowed disabled:opacity-[0.45]"
type="button"
disabled={!nodeRun.logs_object_id || logState?.loading}
onClick={() => void toggleLog(run.run_id, nodeRun.node_run_id)}
>
{logState?.loading ? "读取中…" : logOpen ? "收起日志" : "查看日志"}
</button>
<button
className="cursor-pointer rounded-[4px] border border-[#cbddeb] bg-[#f5faff] p-[6px_10px] text-[10px] text-[#2878bd] disabled:cursor-not-allowed disabled:opacity-[0.45]"
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
className="cursor-pointer rounded-[4px] border border-[#cbddeb] bg-[#f5faff] p-[6px_10px] text-[10px] text-[#2878bd] disabled:cursor-not-allowed disabled:opacity-[0.45]"
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="!text-[#b65353]">
{artifactErrors[nodeRun.node_run_id]}
</p>
)}
{logOpen && (
<div className="ml-[14px] mt-[7px] overflow-hidden rounded-[4px] border border-slate-700 bg-bg-log">
{logState?.fileName && (
<header className="flex items-center justify-between border-b border-slate-700 bg-bg-log p-[5px_7px] text-[10px] text-slate-300">
<span>{logState.fileName}</span>
<small className="text-[#8292a1]">
{formatSize(new TextEncoder().encode(logState.content ?? "").length)}
</small>
</header>
)}
{logState?.error ? (
<p className="m-0 max-h-[260px] overflow-auto p-[11px] font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-words text-slate-200">
{logState.error}
</p>
) : (
<pre className="m-0 max-h-[260px] overflow-auto p-[11px] font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-words text-slate-200">
{logState?.content ?? "正在读取日志…"}
</pre>
)}
</div>
)}
</article>
);
})}
</div>
</>
) : null}
</div>
</section>
</>
)}
</article>
);
})
)}
</div>
</section>
);
}