refactor: extract components
This commit is contained in:
@@ -1,27 +1,223 @@
|
|||||||
import { type ScheduleRunSummary } from "../../services/api";
|
import { useEffect } from "react";
|
||||||
import Icon from "../../components/common/Icon";
|
|
||||||
import { formatTime, formatDuration } from "./utils";
|
|
||||||
|
|
||||||
const RUN_STATUS_LABELS: Record<string, string> = {
|
import {
|
||||||
queued: "排队中",
|
type ScheduleNode,
|
||||||
running: "运行中",
|
type ScheduleRunSummary,
|
||||||
succeeded: "成功",
|
} from "../../services/api";
|
||||||
failed: "失败",
|
|
||||||
cancelled: "已取消",
|
import { useApi } from "../../context/AuthContext";
|
||||||
timed_out: "已超时",
|
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({
|
export function RunHistory({
|
||||||
runs,
|
runs,
|
||||||
|
nodes,
|
||||||
loading,
|
loading,
|
||||||
disabled,
|
disabled,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: {
|
}: {
|
||||||
runs: ScheduleRunSummary[];
|
runs: ScheduleRunSummary[];
|
||||||
|
nodes: ScheduleNode[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
onRefresh: () => void;
|
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<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 (
|
return (
|
||||||
<section className="schedule-run-history">
|
<section className="schedule-run-history">
|
||||||
<header>
|
<header>
|
||||||
@@ -42,21 +238,176 @@ export function RunHistory({
|
|||||||
{loading && runs.length === 0 ? (
|
{loading && runs.length === 0 ? (
|
||||||
<p>正在加载运行记录…</p>
|
<p>正在加载运行记录…</p>
|
||||||
) : runs.length === 0 ? (
|
) : runs.length === 0 ? (
|
||||||
<p>点击右上角"立即运行"后,这里会显示状态和耗时。</p>
|
<p>点击右上角“立即运行”后,这里会显示状态和耗时。</p>
|
||||||
) : (
|
) : (
|
||||||
runs.map((run) => (
|
runs.map((run) => {
|
||||||
<article className="schedule-run-card" key={run.run_id}>
|
const expanded = expandedRunId === run.run_id;
|
||||||
<span className={`run-status-dot is-${run.run_status}`} />
|
const detail = runDetails[run.run_id];
|
||||||
<div>
|
return (
|
||||||
<strong>{RUN_STATUS_LABELS[run.run_status]}</strong>
|
<article
|
||||||
<small>
|
className={`schedule-run-card${expanded ? " is-expanded" : ""}`}
|
||||||
{formatTime(run.queued_at)} · {formatDuration(run.duration_ms)}
|
key={run.run_id}
|
||||||
</small>
|
>
|
||||||
{run.error_message && <p>{run.error_message}</p>}
|
<div
|
||||||
</div>
|
className="schedule-run-summary"
|
||||||
<code title={run.run_id}>{run.run_id.slice(-8)}</code>
|
>
|
||||||
</article>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -262,8 +262,7 @@ export default function SchedulePage({
|
|||||||
const [runsLoading, setRunsLoading] = useState(false);
|
const [runsLoading, setRunsLoading] = useState(false);
|
||||||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||||||
const dragRef = useRef<DragState | null>(null);
|
const dragRef = useRef<DragState | null>(null);
|
||||||
const positionDraftsRef = useRef<Record<string, NodePositionDraft>>({});
|
const positionDraftCount = useSchedulesStore((state) => state.positionDraftCount);
|
||||||
const [positionDraftCount, setPositionDraftCount] = useState(0);
|
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
|
||||||
const selectedNode = schedule?.nodes.find(
|
const selectedNode = schedule?.nodes.find(
|
||||||
@@ -299,12 +298,8 @@ export default function SchedulePage({
|
|||||||
window.removeEventListener("scroll", close, true);
|
window.removeEventListener("scroll", close, true);
|
||||||
window.removeEventListener("keydown", onKeyDown);
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
};
|
};
|
||||||
}, [contextMenu]);
|
}, [contextMenu]);
|
||||||
|
|
||||||
const clearPositionDrafts = (): void => {
|
|
||||||
positionDraftsRef.current = {};
|
|
||||||
setPositionDraftCount(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openContextMenu = (
|
const openContextMenu = (
|
||||||
event: ReactMouseEvent<HTMLElement | SVGElement>,
|
event: ReactMouseEvent<HTMLElement | SVGElement>,
|
||||||
@@ -321,16 +316,16 @@ export default function SchedulePage({
|
|||||||
} as ScheduleContextMenu);
|
} as ScheduleContextMenu);
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyPositionDrafts = (serverSchedule: Schedule): Schedule => {
|
const applyPositionDrafts = (serverSchedule: Schedule): Schedule => {
|
||||||
const drafts = positionDraftsRef.current;
|
const drafts = useSchedulesStore.getState().getPositionDrafts();
|
||||||
if (Object.keys(drafts).length === 0) return serverSchedule;
|
if (drafts.size === 0) return serverSchedule;
|
||||||
return {
|
return {
|
||||||
...serverSchedule,
|
...serverSchedule,
|
||||||
nodes: serverSchedule.nodes.map((node) => {
|
nodes: serverSchedule.nodes.map((node) => {
|
||||||
const draft = drafts[node.node_id];
|
const draft = drafts.get(node.node_id);
|
||||||
return draft ? { ...node, ...draft } : node;
|
return draft ? { ...node, ...draft } : node;
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshRuns = async (
|
const refreshRuns = async (
|
||||||
@@ -484,17 +479,12 @@ export default function SchedulePage({
|
|||||||
if (busy) return null;
|
if (busy) return null;
|
||||||
setBusy(label);
|
setBusy(label);
|
||||||
try {
|
try {
|
||||||
const serverUpdated = await action();
|
const serverUpdated = await action();
|
||||||
const validNodeIds = new Set(
|
const validNodeIds = new Set(
|
||||||
serverUpdated.nodes.map((node) => node.node_id),
|
serverUpdated.nodes.map((node) => node.node_id),
|
||||||
);
|
);
|
||||||
positionDraftsRef.current = Object.fromEntries(
|
useSchedulesStore.getState().prunePositionDrafts(validNodeIds);
|
||||||
Object.entries(positionDraftsRef.current).filter(([nodeId]) => (
|
const updated = applyPositionDrafts(serverUpdated);
|
||||||
validNodeIds.has(nodeId)
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
setPositionDraftCount(Object.keys(positionDraftsRef.current).length);
|
|
||||||
const updated = applyPositionDrafts(serverUpdated);
|
|
||||||
setSchedule(updated);
|
setSchedule(updated);
|
||||||
setSchedules((current) => {
|
setSchedules((current) => {
|
||||||
const summary = { ...updated, nodes: [], edges: [] };
|
const summary = { ...updated, nodes: [], edges: [] };
|
||||||
@@ -518,9 +508,9 @@ export default function SchedulePage({
|
|||||||
|
|
||||||
const chooseSchedule = async (scheduleId: string): Promise<void> => {
|
const chooseSchedule = async (scheduleId: string): Promise<void> => {
|
||||||
if (scheduleId === schedule?.schedule_id || busy) return;
|
if (scheduleId === schedule?.schedule_id || busy) return;
|
||||||
setBusy("load-schedule");
|
setBusy("load-schedule");
|
||||||
clearPositionDrafts();
|
useSchedulesStore.getState().clearAllPositionDrafts();
|
||||||
setSelectedNodeId(null);
|
setSelectedNodeId(null);
|
||||||
setSelectedEdgeId(null);
|
setSelectedEdgeId(null);
|
||||||
setLinkSourceId(null);
|
setLinkSourceId(null);
|
||||||
setCronResult(null);
|
setCronResult(null);
|
||||||
@@ -554,10 +544,10 @@ export default function SchedulePage({
|
|||||||
timezone: "Asia/Shanghai",
|
timezone: "Asia/Shanghai",
|
||||||
enabled: false,
|
enabled: false,
|
||||||
});
|
});
|
||||||
setSchedules((current) => [created, ...current]);
|
setSchedules((current) => [created, ...current]);
|
||||||
setSchedule(created);
|
setSchedule(created);
|
||||||
clearPositionDrafts();
|
useSchedulesStore.getState().clearAllPositionDrafts();
|
||||||
setSelectedNodeId(null);
|
setSelectedNodeId(null);
|
||||||
setCreateDialogOpen(false);
|
setCreateDialogOpen(false);
|
||||||
setNewScheduleName("");
|
setNewScheduleName("");
|
||||||
onNotify({ tone: "success", message: "调度方案已创建" });
|
onNotify({ tone: "success", message: "调度方案已创建" });
|
||||||
@@ -583,10 +573,10 @@ export default function SchedulePage({
|
|||||||
(item) => item.schedule_id !== selectedSchedule.schedule_id,
|
(item) => item.schedule_id !== selectedSchedule.schedule_id,
|
||||||
);
|
);
|
||||||
setSchedules(remaining);
|
setSchedules(remaining);
|
||||||
if (schedule?.schedule_id === selectedSchedule.schedule_id) {
|
if (schedule?.schedule_id === selectedSchedule.schedule_id) {
|
||||||
setSchedule(null);
|
setSchedule(null);
|
||||||
clearPositionDrafts();
|
useSchedulesStore.getState().clearAllPositionDrafts();
|
||||||
setSelectedNodeId(null);
|
setSelectedNodeId(null);
|
||||||
setSelectedEdgeId(null);
|
setSelectedEdgeId(null);
|
||||||
if (remaining[0]) {
|
if (remaining[0]) {
|
||||||
setSchedule(await api.getSchedule(remaining[0].schedule_id));
|
setSchedule(await api.getSchedule(remaining[0].schedule_id));
|
||||||
@@ -665,12 +655,11 @@ export default function SchedulePage({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusy("save-schedule");
|
setBusy("save-schedule");
|
||||||
let updated = useSchedulesStore.getState().schedule ?? schedule;
|
let updated = useSchedulesStore.getState().schedule ?? schedule;
|
||||||
try {
|
try {
|
||||||
for (const [nodeId, position] of Object.entries(
|
const drafts = useSchedulesStore.getState().getPositionDrafts();
|
||||||
positionDraftsRef.current,
|
for (const [nodeId, position] of drafts.entries()) {
|
||||||
)) {
|
updated = await api.updateScheduleNode(updated.schedule_id, nodeId, {
|
||||||
updated = await api.updateScheduleNode(updated.schedule_id, nodeId, {
|
|
||||||
workflow_version: updated.workflow_version,
|
workflow_version: updated.workflow_version,
|
||||||
position_x: position.position_x,
|
position_x: position.position_x,
|
||||||
position_y: position.position_y,
|
position_y: position.position_y,
|
||||||
@@ -686,11 +675,11 @@ export default function SchedulePage({
|
|||||||
: null,
|
: null,
|
||||||
timezone: scheduleForm.timezone.trim(),
|
timezone: scheduleForm.timezone.trim(),
|
||||||
enabled: scheduleForm.enabled,
|
enabled: scheduleForm.enabled,
|
||||||
max_concurrency: maxConcurrency,
|
max_concurrency: maxConcurrency,
|
||||||
failure_policy: scheduleForm.failurePolicy,
|
failure_policy: scheduleForm.failurePolicy,
|
||||||
});
|
});
|
||||||
clearPositionDrafts();
|
useSchedulesStore.getState().clearAllPositionDrafts();
|
||||||
setSchedule(updated);
|
setSchedule(updated);
|
||||||
setSchedules((current) => current.map((item) => (
|
setSchedules((current) => current.map((item) => (
|
||||||
item.schedule_id === updated.schedule_id
|
item.schedule_id === updated.schedule_id
|
||||||
? { ...updated, nodes: [], edges: [] }
|
? { ...updated, nodes: [], edges: [] }
|
||||||
@@ -871,16 +860,12 @@ export default function SchedulePage({
|
|||||||
const current = useSchedulesStore.getState().schedule;
|
const current = useSchedulesStore.getState().schedule;
|
||||||
const node = current?.nodes.find((item) => item.node_id === drag.nodeId);
|
const node = current?.nodes.find((item) => item.node_id === drag.nodeId);
|
||||||
if (!current || !node) return;
|
if (!current || !node) return;
|
||||||
const position = {
|
const position = {
|
||||||
position_x: Math.round(node.position_x),
|
position_x: Math.round(node.position_x),
|
||||||
position_y: Math.round(node.position_y),
|
position_y: Math.round(node.position_y),
|
||||||
};
|
};
|
||||||
positionDraftsRef.current = {
|
useSchedulesStore.getState().setPositionDraft(node.node_id, position);
|
||||||
...positionDraftsRef.current,
|
setSchedule((value) => {
|
||||||
[node.node_id]: position,
|
|
||||||
};
|
|
||||||
setPositionDraftCount(Object.keys(positionDraftsRef.current).length);
|
|
||||||
setSchedule((value) => {
|
|
||||||
if (!value) return value;
|
if (!value) return value;
|
||||||
return {
|
return {
|
||||||
...value,
|
...value,
|
||||||
|
|||||||
@@ -47,14 +47,17 @@ export type NodePositionDraft = {
|
|||||||
position_y: number;
|
position_y: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ScheduleContextMenu = {
|
export type ScheduleContextMenu =
|
||||||
x: number;
|
| { x: number; y: number; kind: "schedule-list" }
|
||||||
y: number;
|
| { x: number; y: number; kind: "schedule"; schedule: Schedule }
|
||||||
kind: "schedule" | "node" | "edge";
|
| {
|
||||||
schedule_id: string;
|
x: number;
|
||||||
node_id?: string;
|
y: number;
|
||||||
edge_id?: string;
|
kind: "artifact";
|
||||||
};
|
artifact: ScheduleArtifact;
|
||||||
|
}
|
||||||
|
| { x: number; y: number; kind: "node"; node: ScheduleNode }
|
||||||
|
| { x: number; y: number; kind: "edge"; edge: ScheduleEdge };
|
||||||
|
|
||||||
export const EMPTY_SCHEDULE_FORM: ScheduleForm = {
|
export const EMPTY_SCHEDULE_FORM: ScheduleForm = {
|
||||||
scheduleName: "",
|
scheduleName: "",
|
||||||
@@ -219,11 +222,18 @@ type Actions = {
|
|||||||
) => void;
|
) => void;
|
||||||
setLoading: (loading: boolean) => void;
|
setLoading: (loading: boolean) => void;
|
||||||
setBusy: (busy: string | null) => void;
|
setBusy: (busy: string | null) => void;
|
||||||
setRuns: (updater: (current: ScheduleRunSummary[]) => ScheduleRunSummary[]) => void;
|
setRuns: (
|
||||||
|
value: ScheduleRunSummary[]
|
||||||
|
| ((current: ScheduleRunSummary[]) => ScheduleRunSummary[]),
|
||||||
|
) => void;
|
||||||
|
setRunsLoading: (loading: boolean) => void;
|
||||||
|
setPositionDraftCount: (count: number) => void;
|
||||||
setRunDetails: (
|
setRunDetails: (
|
||||||
updater: (current: Record<string, ScheduleRunDetail>) => Record<string, ScheduleRunDetail>,
|
updater: (current: Record<string, ScheduleRunDetail>) => Record<string, ScheduleRunDetail>,
|
||||||
) => void;
|
) => void;
|
||||||
setOpenLogNodeRunIds: (updater: (current: Set<string>) => Set<string>) => void;
|
setOpenLogNodeRunIds: (
|
||||||
|
value: Set<string> | ((current: Set<string>) => Set<string>),
|
||||||
|
) => void;
|
||||||
setLogStates: (
|
setLogStates: (
|
||||||
updater: (current: Record<string, {
|
updater: (current: Record<string, {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -289,11 +299,15 @@ type Actions = {
|
|||||||
toggleLog: (runId: string, nodeRunId: string) => Promise<void>;
|
toggleLog: (runId: string, nodeRunId: string) => Promise<void>;
|
||||||
downloadResult: (runId: string, nodeRunId: string) => Promise<void>;
|
downloadResult: (runId: string, nodeRunId: string) => Promise<void>;
|
||||||
downloadLog: (runId: string, nodeRunId: string) => Promise<void>;
|
downloadLog: (runId: string, nodeRunId: string) => Promise<void>;
|
||||||
setExpandedRunId: (id: string | null) => void;
|
setExpandedRunId: (
|
||||||
|
id: string | null | ((current: string | null) => string | null),
|
||||||
|
) => void;
|
||||||
|
|
||||||
// drag helpers
|
// drag helpers
|
||||||
setPositionDraft: (nodeId: string, draft: NodePositionDraft) => void;
|
setPositionDraft: (nodeId: string, draft: NodePositionDraft) => void;
|
||||||
getPositionDrafts: () => Map<string, NodePositionDraft>;
|
getPositionDrafts: () => Map<string, NodePositionDraft>;
|
||||||
|
clearAllPositionDrafts: () => void;
|
||||||
|
prunePositionDrafts: (validNodeIds: Set<string>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const initial: State = {
|
const initial: State = {
|
||||||
@@ -391,10 +405,10 @@ export const useSchedulesStore = create<State & Actions>((set, get) => {
|
|||||||
bindApi: bindSchedulesApi,
|
bindApi: bindSchedulesApi,
|
||||||
|
|
||||||
reset: () => {
|
reset: () => {
|
||||||
clearPositionDrafts();
|
|
||||||
set({ ...initial, loading: true });
|
set({ ...initial, loading: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
// ---- pure setters ----
|
// ---- pure setters ----
|
||||||
|
|
||||||
setSchedule: (value) =>
|
setSchedule: (value) =>
|
||||||
@@ -422,12 +436,27 @@ export const useSchedulesStore = create<State & Actions>((set, get) => {
|
|||||||
})),
|
})),
|
||||||
setLoading: (loading) => set({ loading }),
|
setLoading: (loading) => set({ loading }),
|
||||||
setBusy: (busy) => set({ busy }),
|
setBusy: (busy) => set({ busy }),
|
||||||
setRuns: (updater) =>
|
setRuns: (value) =>
|
||||||
set((state) => ({ runs: updater(state.runs) })),
|
set((state) => ({
|
||||||
|
runs:
|
||||||
|
typeof value === "function"
|
||||||
|
? (value as (current: ScheduleRunSummary[]) => ScheduleRunSummary[])(
|
||||||
|
state.runs,
|
||||||
|
)
|
||||||
|
: value,
|
||||||
|
})),
|
||||||
|
setRunsLoading: (loading) => set({ runsLoading: loading }),
|
||||||
setRunDetails: (updater) =>
|
setRunDetails: (updater) =>
|
||||||
set((state) => ({ runDetails: updater(state.runDetails) })),
|
set((state) => ({ runDetails: updater(state.runDetails) })),
|
||||||
setOpenLogNodeRunIds: (updater) =>
|
setOpenLogNodeRunIds: (value) =>
|
||||||
set((state) => ({ openLogNodeRunIds: updater(state.openLogNodeRunIds) })),
|
set((state) => ({
|
||||||
|
openLogNodeRunIds:
|
||||||
|
typeof value === "function"
|
||||||
|
? (value as (current: Set<string>) => Set<string>)(
|
||||||
|
state.openLogNodeRunIds,
|
||||||
|
)
|
||||||
|
: value,
|
||||||
|
})),
|
||||||
setLogStates: (updater) =>
|
setLogStates: (updater) =>
|
||||||
set((state) => ({ logStates: updater(state.logStates) })),
|
set((state) => ({ logStates: updater(state.logStates) })),
|
||||||
setArtifactBusyKey: (key) => set({ artifactBusyKey: key }),
|
setArtifactBusyKey: (key) => set({ artifactBusyKey: key }),
|
||||||
@@ -522,13 +551,14 @@ export const useSchedulesStore = create<State & Actions>((set, get) => {
|
|||||||
const current = get().schedule;
|
const current = get().schedule;
|
||||||
if (scheduleId === current?.schedule_id || get().busy) return;
|
if (scheduleId === current?.schedule_id || get().busy) return;
|
||||||
set({ busy: "load-schedule" });
|
set({ busy: "load-schedule" });
|
||||||
clearPositionDrafts();
|
get().clearAllPositionDrafts();
|
||||||
set({
|
set({
|
||||||
selectedNodeId: null,
|
selectedNodeId: null,
|
||||||
selectedEdgeId: null,
|
selectedEdgeId: null,
|
||||||
linkSourceId: null,
|
linkSourceId: null,
|
||||||
cronResult: null,
|
cronResult: null,
|
||||||
positionDraftCount: 0,
|
runs: [],
|
||||||
|
runsLoading: false,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
set({ schedule: await api.getSchedule(scheduleId) });
|
set({ schedule: await api.getSchedule(scheduleId) });
|
||||||
@@ -570,8 +600,8 @@ export const useSchedulesStore = create<State & Actions>((set, get) => {
|
|||||||
createDialogOpen: false,
|
createDialogOpen: false,
|
||||||
newScheduleName: "",
|
newScheduleName: "",
|
||||||
}));
|
}));
|
||||||
clearPositionDrafts();
|
get().clearAllPositionDrafts();
|
||||||
set({ positionDraftCount: 0, selectedNodeId: null, selectedEdgeId: null });
|
set({ selectedNodeId: null, selectedEdgeId: null });
|
||||||
notify({ tone: "success", message: "调度方案已创建" });
|
notify({ tone: "success", message: "调度方案已创建" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await handleError(error, "创建调度失败");
|
await handleError(error, "创建调度失败");
|
||||||
@@ -594,12 +624,11 @@ export const useSchedulesStore = create<State & Actions>((set, get) => {
|
|||||||
);
|
);
|
||||||
set({ schedules: remaining });
|
set({ schedules: remaining });
|
||||||
if (get().schedule?.schedule_id === target_.schedule_id) {
|
if (get().schedule?.schedule_id === target_.schedule_id) {
|
||||||
clearPositionDrafts();
|
get().clearAllPositionDrafts();
|
||||||
set({
|
set({
|
||||||
schedule: null,
|
schedule: null,
|
||||||
selectedNodeId: null,
|
selectedNodeId: null,
|
||||||
selectedEdgeId: null,
|
selectedEdgeId: null,
|
||||||
positionDraftCount: 0,
|
|
||||||
});
|
});
|
||||||
if (remaining[0]) {
|
if (remaining[0]) {
|
||||||
const detail = await api.getSchedule(remaining[0].schedule_id);
|
const detail = await api.getSchedule(remaining[0].schedule_id);
|
||||||
@@ -714,7 +743,7 @@ export const useSchedulesStore = create<State & Actions>((set, get) => {
|
|||||||
max_concurrency: maxConcurrency,
|
max_concurrency: maxConcurrency,
|
||||||
failure_policy: scheduleForm.failurePolicy,
|
failure_policy: scheduleForm.failurePolicy,
|
||||||
});
|
});
|
||||||
clearPositionDrafts();
|
get().clearAllPositionDrafts();
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
schedule: updated,
|
schedule: updated,
|
||||||
schedules: s.schedules.map((item) =>
|
schedules: s.schedules.map((item) =>
|
||||||
@@ -722,8 +751,8 @@ export const useSchedulesStore = create<State & Actions>((set, get) => {
|
|||||||
? { ...updated, nodes: [], edges: [] }
|
? { ...updated, nodes: [], edges: [] }
|
||||||
: item
|
: item
|
||||||
),
|
),
|
||||||
positionDraftCount: 0,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
notify({ tone: "success", message: "调度配置已保存" });
|
notify({ tone: "success", message: "调度配置已保存" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const localDraft = applyPositionDrafts(updated);
|
const localDraft = applyPositionDrafts(updated);
|
||||||
@@ -994,7 +1023,15 @@ export const useSchedulesStore = create<State & Actions>((set, get) => {
|
|||||||
set({ expandedRunId: runId });
|
set({ expandedRunId: runId });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setExpandedRunId: (id) => set({ expandedRunId: id }),
|
setExpandedRunId: (id) =>
|
||||||
|
set((state) => ({
|
||||||
|
expandedRunId:
|
||||||
|
typeof id === "function"
|
||||||
|
? (id as (current: string | null) => string | null)(
|
||||||
|
state.expandedRunId,
|
||||||
|
)
|
||||||
|
: id,
|
||||||
|
})),
|
||||||
|
|
||||||
toggleLog: async (runId, nodeRunId) => {
|
toggleLog: async (runId, nodeRunId) => {
|
||||||
const api = requireApi();
|
const api = requireApi();
|
||||||
@@ -1138,7 +1175,18 @@ export const useSchedulesStore = create<State & Actions>((set, get) => {
|
|||||||
positionDrafts.set(nodeId, draft);
|
positionDrafts.set(nodeId, draft);
|
||||||
set({ positionDraftCount: positionDrafts.size });
|
set({ positionDraftCount: positionDrafts.size });
|
||||||
},
|
},
|
||||||
|
setPositionDraftCount: (count) => set({ positionDraftCount: count }),
|
||||||
getPositionDrafts: () => positionDrafts,
|
getPositionDrafts: () => positionDrafts,
|
||||||
|
clearAllPositionDrafts: () => {
|
||||||
|
clearPositionDrafts();
|
||||||
|
set({ positionDraftCount: 0 });
|
||||||
|
},
|
||||||
|
prunePositionDrafts: (validNodeIds) => {
|
||||||
|
for (const nodeId of [...positionDrafts.keys()]) {
|
||||||
|
if (!validNodeIds.has(nodeId)) positionDrafts.delete(nodeId);
|
||||||
|
}
|
||||||
|
set({ positionDraftCount: positionDrafts.size });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1157,4 +1205,4 @@ function artifactNodeKey(artifact: ScheduleArtifact, schedule: Schedule): string
|
|||||||
if (!existing.has(candidate)) return candidate;
|
if (!existing.has(candidate)) return candidate;
|
||||||
}
|
}
|
||||||
return `${base}_${Date.now().toString(36)}`;
|
return `${base}_${Date.now().toString(36)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { type Schedule, type ScheduleArtifact, type ScheduleNode } from "../../services/api";
|
import {
|
||||||
|
type Schedule,
|
||||||
|
type ScheduleArtifact,
|
||||||
|
type ScheduleNode,
|
||||||
|
type ScheduleNodeRun,
|
||||||
|
type ScheduleRunSummary,
|
||||||
|
} from "../../services/api";
|
||||||
import { EMPTY_SCHEDULE_FORM, EMPTY_NODE_FORM } from "./constants";
|
import { EMPTY_SCHEDULE_FORM, EMPTY_NODE_FORM } from "./constants";
|
||||||
|
|
||||||
export function formatTime(value: string | null): string {
|
export function formatTime(value: string | null): string {
|
||||||
@@ -23,6 +29,33 @@ export function formatDuration(value: number | null): string {
|
|||||||
return `${Math.floor(value / 60_000)}m ${Math.round((value % 60_000) / 1000)}s`;
|
return `${Math.floor(value / 60_000)}m ${Math.round((value % 60_000) / 1000)}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export 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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RUN_STATUS_LABELS: Record<ScheduleRunSummary["run_status"], string> = {
|
||||||
|
queued: "排队中",
|
||||||
|
running: "运行中",
|
||||||
|
succeeded: "成功",
|
||||||
|
failed: "失败",
|
||||||
|
cancelled: "已取消",
|
||||||
|
timed_out: "已超时",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NODE_STATUS_LABELS: Record<ScheduleNodeRun["node_status"], string> = {
|
||||||
|
...RUN_STATUS_LABELS,
|
||||||
|
skipped: "已跳过",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TRIGGER_TYPE_LABELS: Record<ScheduleRunSummary["trigger_type"], string> = {
|
||||||
|
manual: "手动触发",
|
||||||
|
cron: "Cron 定时",
|
||||||
|
api: "API 触发",
|
||||||
|
retry: "失败重试",
|
||||||
|
};
|
||||||
|
|
||||||
export function parseObject(text: string, label: string): Record<string, unknown> {
|
export function parseObject(text: string, label: string): Record<string, unknown> {
|
||||||
let value: unknown;
|
let value: unknown;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user