添加查看调度运行结果

This commit is contained in:
Winnie
2026-08-05 16:34:35 +08:00
parent c263ae6a5f
commit 30cde4a56c
8 changed files with 1140 additions and 36 deletions
+2
View File
@@ -253,5 +253,7 @@ export function useApi(): WorkspaceBoundApi {
runScheduleNow: (scheduleId) => rawApi.runScheduleNow(workspaceId, scheduleId),
listScheduleRuns: (input) => rawApi.listScheduleRuns(workspaceId, input),
getScheduleRun: (runId) => rawApi.getScheduleRun(workspaceId, runId),
getScheduleNodeRunArtifacts: (runId, nodeRunId) =>
rawApi.getScheduleNodeRunArtifacts(workspaceId, runId, nodeRunId),
}), [workspaceId]);
}
+377 -13
View File
@@ -16,6 +16,8 @@ import {
type ScheduleArtifact,
type ScheduleEdge,
type ScheduleNode,
type ScheduleNodeRun,
type ScheduleRunDetail,
type ScheduleRunSummary,
} from "../../services/api";
@@ -129,6 +131,18 @@ const RUN_STATUS_LABELS: Record<ScheduleRunSummary["run_status"], string> = {
timed_out: "已超时",
};
const NODE_STATUS_LABELS: Record<ScheduleNodeRun["node_status"], string> = {
...RUN_STATUS_LABELS,
skipped: "已跳过",
};
const TRIGGER_TYPE_LABELS: Record<ScheduleRunSummary["trigger_type"], string> = {
manual: "手动触发",
cron: "Cron 定时",
api: "API 触发",
retry: "失败重试",
};
function formatDuration(value: number | null): string {
if (value === null) return "—";
if (value < 1000) return `${value} ms`;
@@ -136,6 +150,12 @@ function formatDuration(value: number | null): string {
return `${Math.floor(value / 60_000)}m ${Math.round((value % 60_000) / 1000)}s`;
}
function formatSize(value: number): string {
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
function parseObject(text: string, label: string): Record<string, unknown> {
let value: unknown;
try {
@@ -1399,6 +1419,7 @@ export default function SchedulePage({
</div>
<RunHistory
runs={runs}
nodes={schedule?.nodes ?? []}
loading={runsLoading}
disabled={!schedule || Boolean(busy)}
onRefresh={() => {
@@ -1562,15 +1583,203 @@ export default function SchedulePage({
function RunHistory({
runs,
nodes,
loading,
disabled,
onRefresh,
}: {
runs: ScheduleRunSummary[];
nodes: ScheduleNode[];
loading: boolean;
disabled: boolean;
onRefresh: () => void;
}) {
const api = useApi();
const [expandedRunId, setExpandedRunId] = useState<string | null>(null);
const [runDetails, setRunDetails] = useState<Record<string, ScheduleRunDetail>>({});
const [detailLoadingId, setDetailLoadingId] = useState<string | null>(null);
const [detailErrors, setDetailErrors] = useState<Record<string, string>>({});
const [openLogNodeRunIds, setOpenLogNodeRunIds] = useState<Set<string>>(
() => new Set(),
);
const [logStates, setLogStates] = useState<Record<string, {
loading: boolean;
content: string | null;
fileName: string | null;
error: string | null;
}>>({});
const [artifactBusyKey, setArtifactBusyKey] = useState<string | null>(null);
const [artifactErrors, setArtifactErrors] = useState<Record<string, string>>({});
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((current) => current === runId ? null : runId);
setOpenLogNodeRunIds(new Set());
};
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>
@@ -1593,19 +1802,174 @@ function RunHistory({
) : runs.length === 0 ? (
<p></p>
) : (
runs.map((run) => (
<article className="schedule-run-card" key={run.run_id}>
<span className={`run-status-dot is-${run.run_status}`} />
<div>
<strong>{RUN_STATUS_LABELS[run.run_status]}</strong>
<small>
{formatTime(run.queued_at)} · {formatDuration(run.duration_ms)}
</small>
{run.error_message && <p>{run.error_message}</p>}
</div>
<code title={run.run_id}>{run.run_id.slice(-8)}</code>
</article>
))
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>
+30
View File
@@ -745,6 +745,20 @@ export type ScheduleRunDetail = ScheduleRunSummary & {
node_runs: ScheduleNodeRun[];
};
export type ScheduleRunArtifact = {
url: string;
file_name: string;
mime_type: string | null;
size_bytes: number;
};
export type ScheduleNodeRunArtifacts = {
run_id: string;
node_run_id: string;
log: ScheduleRunArtifact | null;
result: ScheduleRunArtifact | null;
};
export async function listSchedules(workspaceId: string): Promise<Schedule[]> {
return apiRequest<Schedule[]>("/api/v1/schedules", {}, workspaceId);
}
@@ -1055,6 +1069,18 @@ export async function getScheduleRun(
);
}
export async function getScheduleNodeRunArtifacts(
workspaceId: string,
runId: string,
nodeRunId: string,
): Promise<ScheduleNodeRunArtifacts> {
return apiRequest<ScheduleNodeRunArtifacts>(
`/api/v1/schedule-runs/${runId}/node-runs/${nodeRunId}/artifacts`,
{},
workspaceId,
);
}
// ----------------------------------------------------------------------------
// Workspace-bound API surface.
//
@@ -1177,4 +1203,8 @@ export type WorkspaceBoundApi = {
input?: Parameters<typeof listScheduleRuns>[1],
) => Promise<ScheduleRunSummary[]>;
getScheduleRun: (runId: string) => Promise<ScheduleRunDetail>;
getScheduleNodeRunArtifacts: (
runId: string,
nodeRunId: string,
) => Promise<ScheduleNodeRunArtifacts>;
};
+362 -12
View File
@@ -700,7 +700,7 @@
.schedule-right {
display: grid;
overflow: hidden;
grid-template-rows: minmax(0, 1fr) 236px;
grid-template-rows: repeat(2, minmax(0, 1fr));
}
.schedule-inspector-scroll {
@@ -978,35 +978,53 @@
}
.schedule-run-card {
display: grid;
align-items: start;
gap: 7px;
padding: 8px;
flex: 0 0 auto;
overflow: hidden;
border: 1px solid #e1e8ef;
border-radius: 6px;
background: #fff;
grid-template-columns: 8px minmax(0, 1fr) auto;
}
.schedule-run-card > div {
.schedule-run-card.is-expanded {
border-color: #b9d6f1;
box-shadow: 0 3px 10px rgb(38 117 185 / 8%);
}
.schedule-run-summary {
display: grid;
width: 100%;
align-items: center;
gap: 7px;
padding: 8px;
border: 0;
color: inherit;
background: #fff;
grid-template-columns: 8px minmax(0, 1fr) auto auto;
text-align: left;
}
.schedule-run-summary:hover {
background: #f7fbff;
}
.schedule-run-summary-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.schedule-run-card strong {
.schedule-run-summary-copy strong {
color: #425970;
font-size: 10px;
}
.schedule-run-card small {
.schedule-run-summary-copy small {
color: #8b99a8;
font-size: 8px;
}
.schedule-run-card p {
margin: 2px 0 0;
.schedule-run-summary-copy > span {
overflow: hidden;
color: #b65353;
font-size: 8px;
@@ -1015,12 +1033,340 @@
white-space: nowrap;
}
.schedule-run-card code {
.schedule-run-summary code {
color: #8b9bad;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 8px;
}
.schedule-run-result-button {
min-height: 27px;
padding: 0 9px;
border: 1px solid #bcd4e9;
border-radius: 5px;
color: #2374ba;
background: #f5faff;
cursor: pointer;
font-size: 9px;
font-weight: 650;
white-space: nowrap;
}
.schedule-run-result-button:hover {
border-color: #7eafe0;
background: #eaf5ff;
}
.schedule-run-chevron {
display: grid;
margin-top: 1px;
place-items: center;
color: #8092a5;
transition: transform .16s ease;
}
.schedule-run-chevron.is-open {
transform: rotate(90deg);
}
.schedule-run-detail {
min-height: 0;
padding: 18px;
overflow: auto;
background: #f8fbfe;
}
.schedule-run-result-backdrop {
position: fixed;
z-index: 40;
inset: 0;
border: 0;
background: rgb(10 27 45 / 46%);
cursor: default;
backdrop-filter: blur(2px);
}
.schedule-run-result-modal {
position: fixed;
z-index: 41;
top: 50%;
left: 50%;
display: grid;
width: min(780px, calc(100vw - 48px));
max-height: min(760px, calc(100vh - 64px));
overflow: hidden;
border: 1px solid #d8e2eb;
border-radius: 12px;
background: #fff;
box-shadow: 0 24px 80px rgb(8 27 48 / 30%);
grid-template-rows: auto minmax(0, 1fr);
transform: translate(-50%, -50%);
animation: modal-in .18s ease-out;
}
.schedule-run-result-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 17px 20px;
border-bottom: 1px solid #e3eaf0;
background: #fff;
}
.schedule-run-result-modal__header > div {
display: grid;
min-width: 0;
align-items: center;
gap: 5px 10px;
grid-template-columns: auto auto;
}
.schedule-run-result-modal__header span {
color: #1f344a;
font-size: 17px;
font-weight: 700;
}
.schedule-run-result-modal__header strong {
justify-self: start;
padding: 3px 8px;
border-radius: 11px;
color: #17805a;
background: #e8f7f1;
font-size: 10px;
}
.schedule-run-result-modal__header code {
overflow: hidden;
color: #8291a1;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 10px;
grid-column: 1 / -1;
text-overflow: ellipsis;
white-space: nowrap;
}
.schedule-run-result-modal .schedule-run-meta {
margin-bottom: 14px;
gap: 10px 18px;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.schedule-run-result-modal .schedule-run-meta dt {
margin-bottom: 4px;
font-size: 10px;
}
.schedule-run-result-modal .schedule-run-meta dd {
font-size: 12px;
}
.schedule-run-result-modal .schedule-node-run-list {
gap: 10px;
}
.schedule-run-result-modal .schedule-node-run {
padding: 12px;
border-radius: 7px;
}
.schedule-run-result-modal .schedule-node-run > header strong {
font-size: 13px;
}
.schedule-run-result-modal .schedule-node-run > header > span:last-child,
.schedule-run-result-modal .schedule-node-run-meta,
.schedule-run-result-modal .schedule-node-run > p {
font-size: 10px;
}
.schedule-run-result-modal .schedule-node-run-actions button {
padding: 6px 10px;
font-size: 10px;
}
.schedule-run-result-modal .schedule-node-log > header {
padding: 8px 10px;
font-size: 10px;
}
.schedule-run-result-modal .schedule-node-log pre,
.schedule-run-result-modal .schedule-node-log > p {
max-height: 260px;
padding: 11px;
font-size: 11px;
}
.run-detail-message {
margin: 0;
padding: 8px 4px;
color: #8796a6;
font-size: 9px;
line-height: 1.5;
text-align: center;
}
.run-detail-message.is-error {
color: #bf5656;
}
.schedule-run-meta {
display: grid;
margin: 0 0 8px;
gap: 6px 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.schedule-run-meta > div {
min-width: 0;
}
.schedule-run-meta dt {
margin-bottom: 1px;
color: #95a2af;
font-size: 8px;
}
.schedule-run-meta dd {
margin: 0;
overflow: hidden;
color: #41566c;
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.schedule-run-error {
margin-bottom: 8px;
padding: 7px;
border: 1px solid #f0cece;
border-radius: 5px;
color: #a94f4f;
background: #fff6f6;
}
.schedule-run-error code {
font-size: 8px;
font-weight: 700;
}
.schedule-run-error p {
margin: 3px 0 0;
font-size: 8px;
line-height: 1.45;
}
.schedule-node-run-list {
display: flex;
flex-direction: column;
gap: 7px;
}
.schedule-node-run {
padding: 7px;
border: 1px solid #dde7f0;
border-radius: 5px;
background: #fff;
}
.schedule-node-run > header {
display: grid;
align-items: start;
gap: 6px;
grid-template-columns: 8px minmax(0, 1fr) auto;
}
.schedule-node-run > header strong {
overflow: hidden;
color: #40566c;
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.schedule-node-run > header > span:last-child {
color: #71869a;
font-size: 8px;
}
.schedule-node-run-meta {
display: flex;
flex-wrap: wrap;
gap: 3px 8px;
margin: 5px 0 0 14px;
color: #8a98a7;
font-size: 8px;
}
.schedule-node-run > p {
margin: 5px 0 0 14px;
color: #6c7e90;
font-size: 8px;
line-height: 1.45;
}
.schedule-node-run-actions {
display: flex;
gap: 6px;
margin: 7px 0 0 14px;
}
.schedule-node-run-actions button {
padding: 4px 7px;
border: 1px solid #cbddeb;
border-radius: 4px;
color: #2878bd;
background: #f5faff;
cursor: pointer;
font-size: 8px;
}
.schedule-node-run-actions button:disabled {
cursor: not-allowed;
opacity: .45;
}
.schedule-artifact-error {
color: #b65353 !important;
}
.schedule-node-log {
margin: 7px 0 0 14px;
overflow: hidden;
border: 1px solid #dce5ed;
border-radius: 4px;
background: #17212b;
}
.schedule-node-log > header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 7px;
border-bottom: 1px solid #33414e;
color: #c8d2dc;
font-size: 8px;
}
.schedule-node-log > header small {
color: #8292a1;
}
.schedule-node-log pre,
.schedule-node-log > p {
max-height: 210px;
margin: 0;
padding: 7px;
overflow: auto;
color: #d9e2ea;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 8px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
.run-status-dot {
width: 7px;
height: 7px;
@@ -1048,6 +1394,10 @@
background: #d75b5b;
}
.run-status-dot.is-skipped {
background: #a9b4bf;
}
.node-inspector-title {
flex-direction: row !important;
align-items: center;