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

2204 lines
74 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 {
type DragEvent,
type FormEvent,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
ApiRequestError,
type CronPreview,
type Schedule,
type ScheduleArtifact,
type ScheduleEdge,
type ScheduleNode,
type ScheduleNodeRun,
type ScheduleRunDetail,
type ScheduleRunSummary,
} from "../../services/api";
import { useApi, useAuth } from "../../context/AuthContext";
import Icon from "../../components/common/Icon";
import { ScheduleList } from "./ScheduleList";
import { ArtifactList } from "./ArtifactList";
import { ScheduleCanvasHeader } from "./ScheduleCanvasHeader";
import { useSchedulesStore } from "./state/schedulesStore";
import "../../styles/schedule.css";
type Notice = {
tone: "success" | "error" | "info";
message: string;
};
type ScheduleForm = {
scheduleName: string;
description: string;
triggerType: "manual" | "cron" | "api";
cronExpression: string;
timezone: string;
enabled: boolean;
maxConcurrency: string;
failurePolicy: "stop" | "continue";
};
type NodeForm = {
nodeName: string;
timeoutSeconds: string;
retryCount: string;
retryIntervalSec: string;
argumentsJson: string;
envRefsJson: string;
};
type DragState = {
nodeId: string;
pointerId: number;
startClientX: number;
startClientY: number;
originX: number;
originY: number;
moved: boolean;
};
type NodePositionDraft = {
position_x: number;
position_y: number;
};
type ScheduleContextMenu =
| { kind: "schedule-list"; x: number; y: number }
| { kind: "schedule"; x: number; y: number; schedule: Schedule }
| { kind: "artifact"; x: number; y: number; artifact: ScheduleArtifact }
| { kind: "node"; x: number; y: number; node: ScheduleNode }
| { kind: "edge"; x: number; y: number; edge: ScheduleEdge };
type ScheduleContextMenuTarget =
| { kind: "schedule-list" }
| { kind: "schedule"; schedule: Schedule }
| { kind: "artifact"; artifact: ScheduleArtifact }
| { kind: "node"; node: ScheduleNode }
| { kind: "edge"; edge: ScheduleEdge };
const EMPTY_SCHEDULE_FORM: ScheduleForm = {
scheduleName: "",
description: "",
triggerType: "manual",
cronExpression: "0 9 * * *",
timezone: "Asia/Shanghai",
enabled: false,
maxConcurrency: "1",
failurePolicy: "stop",
};
const EMPTY_NODE_FORM: NodeForm = {
nodeName: "",
timeoutSeconds: "600",
retryCount: "0",
retryIntervalSec: "5",
argumentsJson: "{}",
envRefsJson: "{}",
};
const CANVAS_WIDTH = 1400;
const CANVAS_HEIGHT = 860;
const NODE_WIDTH = 218;
const NODE_HEIGHT = 104;
const ARTIFACT_MIME = "application/x-model-platform-version";
function formatTime(value: string | null): string {
if (!value) return "尚未执行";
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(new Date(value));
}
function shortHash(value: string): string {
return value ? `${value.slice(0, 7)}${value.slice(-5)}` : "—";
}
const RUN_STATUS_LABELS: Record<ScheduleRunSummary["run_status"], string> = {
queued: "排队中",
running: "运行中",
succeeded: "成功",
failed: "失败",
cancelled: "已取消",
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`;
if (value < 60_000) return `${(value / 1000).toFixed(1)} s`;
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 {
value = JSON.parse(text);
} catch {
throw new Error(`${label}必须是合法 JSON`);
}
if (!value || Array.isArray(value) || typeof value !== "object") {
throw new Error(`${label}必须是 JSON 对象`);
}
return value as Record<string, unknown>;
}
function scheduleToForm(schedule: Schedule): ScheduleForm {
return {
scheduleName: schedule.schedule_name,
description: schedule.description ?? "",
triggerType: schedule.trigger_type,
cronExpression: schedule.cron_expression ?? "0 9 * * *",
timezone: schedule.timezone,
enabled: schedule.enabled,
maxConcurrency: String(schedule.max_concurrency),
failurePolicy: schedule.failure_policy,
};
}
function nodeToForm(node: ScheduleNode): NodeForm {
return {
nodeName: node.node_name,
timeoutSeconds: String(node.timeout_seconds),
retryCount: String(node.retry_count),
retryIntervalSec: String(node.retry_interval_sec),
argumentsJson: JSON.stringify(node.arguments_json ?? {}, null, 2),
envRefsJson: JSON.stringify(node.env_refs_json ?? {}, null, 2),
};
}
function artifactNodeKey(
artifact: ScheduleArtifact,
schedule: Schedule,
): string {
const ascii = artifact.script_name
.replace(/\.[^.]+$/, "")
.replace(/[^A-Za-z0-9_-]+/g, "_")
.replace(/^([^A-Za-z])/, "n_$1")
.replace(/^_+|_+$/g, "")
.slice(0, 48);
const base = ascii || `node_${artifact.versions_id.slice(-6).toLowerCase()}`;
const existing = new Set(schedule.nodes.map((item) => item.node_key));
if (!existing.has(base)) return base;
let index = 2;
while (existing.has(`${base}_${index}`)) index += 1;
return `${base}_${index}`.slice(0, 64);
}
function edgePath(
source: ScheduleNode,
target: ScheduleNode,
): string {
const x1 = source.position_x + NODE_WIDTH;
const y1 = source.position_y + NODE_HEIGHT / 2;
const x2 = target.position_x;
const y2 = target.position_y + NODE_HEIGHT / 2;
const curve = Math.max(70, Math.abs(x2 - x1) * 0.45);
return `M ${x1} ${y1} C ${x1 + curve} ${y1}, ${x2 - curve} ${y2}, ${x2} ${y2}`;
}
export default function SchedulePage({
onNotify,
onConnectionChange,
}: {
onNotify: (notice: Notice) => void;
onConnectionChange: (online: boolean) => void;
}) {
const { currentWorkspace } = useAuth();
const workspaceId = currentWorkspace?.workspace_id;
// 核心数据 state 迁 store(其余 form/dialog/selected/runs 仍用 useState)
const schedules = useSchedulesStore((s) => s.schedules);
const artifacts = useSchedulesStore((s) => s.artifacts);
const schedule = useSchedulesStore((s) => s.schedule);
const loading = useSchedulesStore((s) => s.loading);
const busy = useSchedulesStore((s) => s.busy);
const setSchedules = useSchedulesStore((s) => s.setSchedules);
const setArtifacts = useSchedulesStore((s) => s.setArtifacts);
const setSchedule = useSchedulesStore((s) => s.setSchedule);
const setLoading = useSchedulesStore((s) => s.setLoading);
const setBusy = useSchedulesStore((s) => s.setBusy);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const [linkSourceId, setLinkSourceId] = useState<string | null>(null);
const [scheduleKeyword, setScheduleKeyword] = useState("");
const [artifactKeyword, setArtifactKeyword] = useState("");
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [newScheduleName, setNewScheduleName] = useState("");
const [contextMenu, setContextMenu] = useState<ScheduleContextMenu | null>(null);
const [scheduleForm, setScheduleForm] = useState<ScheduleForm>(EMPTY_SCHEDULE_FORM);
const [nodeForm, setNodeForm] = useState<NodeForm>(EMPTY_NODE_FORM);
const [cronResult, setCronResult] = useState<CronPreview | null>(null);
const [runs, setRuns] = useState<ScheduleRunSummary[]>([]);
const [runsLoading, setRunsLoading] = useState(false);
const canvasRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<DragState | null>(null);
const positionDraftsRef = useRef<Record<string, NodePositionDraft>>({});
const [positionDraftCount, setPositionDraftCount] = useState(0);
const api = useApi();
const selectedNode = schedule?.nodes.find(
(item) => item.node_id === selectedNodeId,
) ?? null;
const selectedEdge = schedule?.edges.find(
(item) => item.edge_id === selectedEdgeId,
) ?? null;
useEffect(() => {
if (schedule) setScheduleForm(scheduleToForm(schedule));
}, [schedule?.schedule_id, schedule?.workflow_version]);
useEffect(() => {
setNodeForm(selectedNode ? nodeToForm(selectedNode) : EMPTY_NODE_FORM);
}, [selectedNode?.node_id, selectedNode?.updated_at]);
useEffect(() => {
if (!contextMenu) return undefined;
const close = (): void => setContextMenu(null);
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === "Escape") close();
};
window.addEventListener("pointerdown", close);
window.addEventListener("blur", close);
window.addEventListener("resize", close);
window.addEventListener("scroll", close, true);
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("pointerdown", close);
window.removeEventListener("blur", close);
window.removeEventListener("resize", close);
window.removeEventListener("scroll", close, true);
window.removeEventListener("keydown", onKeyDown);
};
}, [contextMenu]);
const clearPositionDrafts = (): void => {
positionDraftsRef.current = {};
setPositionDraftCount(0);
};
const openContextMenu = (
event: ReactMouseEvent<HTMLElement | SVGElement>,
target: ScheduleContextMenuTarget,
): void => {
event.preventDefault();
event.stopPropagation();
const menuWidth = 176;
const menuHeight = target.kind === "schedule" ? 132 : 48;
setContextMenu({
...target,
x: Math.min(event.clientX, window.innerWidth - menuWidth - 8),
y: Math.min(event.clientY, window.innerHeight - menuHeight - 8),
} as ScheduleContextMenu);
};
const applyPositionDrafts = (serverSchedule: Schedule): Schedule => {
const drafts = positionDraftsRef.current;
if (Object.keys(drafts).length === 0) return serverSchedule;
return {
...serverSchedule,
nodes: serverSchedule.nodes.map((node) => {
const draft = drafts[node.node_id];
return draft ? { ...node, ...draft } : node;
}),
};
};
const refreshRuns = async (
scheduleId: string,
showLoading = false,
): Promise<void> => {
if (showLoading) setRunsLoading(true);
try {
const items = await api.listScheduleRuns({
scheduleId,
limit: 20,
});
if (useSchedulesStore.getState().schedule?.schedule_id === scheduleId) {
setRuns(items);
}
onConnectionChange(true);
} catch (error) {
if (showLoading) {
await handleError(error, "运行记录加载失败");
}
} finally {
if (showLoading) setRunsLoading(false);
}
};
const refreshLists = async (
preferredScheduleId?: string | null,
): Promise<void> => {
const [scheduleItems, artifactItems] = await Promise.all([
api.listSchedules(),
api.listScheduleArtifacts(),
]);
setSchedules(scheduleItems);
setArtifacts(artifactItems);
const targetId = preferredScheduleId
?? useSchedulesStore.getState().schedule?.schedule_id
?? scheduleItems[0]?.schedule_id
?? null;
if (!targetId) {
setSchedule(null);
return;
}
const detail = await api.getSchedule(targetId);
setSchedule(applyPositionDrafts(detail));
};
useEffect(() => {
let cancelled = false;
setLoading(true);
Promise.all([api.listSchedules(), api.listScheduleArtifacts()])
.then(async ([scheduleItems, artifactItems]) => {
if (cancelled) return;
setSchedules(scheduleItems);
setArtifacts(artifactItems);
if (scheduleItems[0]) {
const detail = await api.getSchedule(scheduleItems[0].schedule_id);
if (!cancelled) setSchedule(applyPositionDrafts(detail));
}
onConnectionChange(true);
})
.catch((error: unknown) => {
if (cancelled) return;
onConnectionChange(false);
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "调度数据加载失败",
});
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
// 切 workspace 时清空 store 与 local state 并重新加载
useEffect(() => {
if (!workspaceId) return;
setSchedules([]);
setArtifacts([]);
setSchedule(null);
setRuns([]);
setRunsLoading(true);
void useSchedulesStore.getState().loadInitial();
}, [workspaceId, setSchedules, setArtifacts, setSchedule]);
useEffect(() => {
const scheduleId = schedule?.schedule_id;
if (!scheduleId) {
setRuns([]);
return;
}
let cancelled = false;
setRunsLoading(true);
api.listScheduleRuns({ scheduleId, limit: 20 })
.then((items) => {
if (!cancelled) setRuns(items);
})
.catch((error: unknown) => {
if (!cancelled) {
onNotify({
tone: "error",
message: error instanceof Error ? error.message : "运行记录加载失败",
});
}
})
.finally(() => {
if (!cancelled) setRunsLoading(false);
});
return () => {
cancelled = true;
};
}, [schedule?.schedule_id]);
useEffect(() => {
const scheduleId = schedule?.schedule_id;
if (!scheduleId || !runs.some((item) => item.run_status === "queued" || item.run_status === "running")) return;
const timer = window.setInterval(() => {
void refreshRuns(scheduleId);
}, 1500);
return () => window.clearInterval(timer);
}, [schedule?.schedule_id, runs]);
const handleError = async (
error: unknown,
fallback: string,
): Promise<void> => {
if (error instanceof ApiRequestError && error.status === 412) {
const currentId = useSchedulesStore.getState().schedule?.schedule_id;
if (currentId) {
withSuppressedError(() => refreshLists(currentId));
}
onNotify({
tone: "error",
message: "调度已被其他操作更新,已重新加载最新版本",
});
return;
}
onNotify({
tone: "error",
message: error instanceof Error ? error.message : fallback,
});
};
const withMutation = async (
label: string,
action: () => Promise<Schedule>,
successMessage: string,
): Promise<Schedule | null> => {
if (busy) return null;
setBusy(label);
try {
const serverUpdated = await action();
const validNodeIds = new Set(
serverUpdated.nodes.map((node) => node.node_id),
);
positionDraftsRef.current = Object.fromEntries(
Object.entries(positionDraftsRef.current).filter(([nodeId]) => (
validNodeIds.has(nodeId)
)),
);
setPositionDraftCount(Object.keys(positionDraftsRef.current).length);
const updated = applyPositionDrafts(serverUpdated);
setSchedule(updated);
setSchedules((current) => {
const summary = { ...updated, nodes: [], edges: [] };
const index = current.findIndex(
(item) => item.schedule_id === updated.schedule_id,
);
if (index < 0) return [summary, ...current];
return current.map((item) => (
item.schedule_id === updated.schedule_id ? summary : item
));
});
onNotify({ tone: "success", message: successMessage });
return updated;
} catch (error) {
await handleError(error, `${successMessage}失败`);
return null;
} finally {
setBusy(null);
}
};
const chooseSchedule = async (scheduleId: string): Promise<void> => {
if (scheduleId === schedule?.schedule_id || busy) return;
setBusy("load-schedule");
clearPositionDrafts();
setSelectedNodeId(null);
setSelectedEdgeId(null);
setLinkSourceId(null);
setCronResult(null);
try {
setSchedule(await api.getSchedule(scheduleId));
onConnectionChange(true);
} catch (error) {
await handleError(error, "调度详情加载失败");
} finally {
setBusy(null);
}
};
const openCreateScheduleDialog = (): void => {
if (busy) return;
setContextMenu(null);
setNewScheduleName(`新建调度 ${schedules.length + 1}`);
setCreateDialogOpen(true);
};
const addSchedule = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
const scheduleName = newScheduleName.trim();
if (busy || !scheduleName) return;
setBusy("create-schedule");
try {
const created = await api.createSchedule({
schedule_name: scheduleName,
description: "在画布中拖入稳定版本并配置执行顺序",
trigger_type: "manual",
timezone: "Asia/Shanghai",
enabled: false,
});
setSchedules((current) => [created, ...current]);
setSchedule(created);
clearPositionDrafts();
setSelectedNodeId(null);
setCreateDialogOpen(false);
setNewScheduleName("");
onNotify({ tone: "success", message: "调度方案已创建" });
} catch (error) {
await handleError(error, "创建调度失败");
} finally {
setBusy(null);
}
};
const removeSchedule = async (target?: Schedule): Promise<void> => {
const selectedSchedule = target ?? schedule;
if (!selectedSchedule || busy) return;
setContextMenu(null);
if (!window.confirm(`确定删除调度"${selectedSchedule.schedule_name}"吗?`)) return;
setBusy("delete-schedule");
try {
await api.deleteSchedule(
selectedSchedule.schedule_id,
selectedSchedule.workflow_version,
);
const remaining = schedules.filter(
(item) => item.schedule_id !== selectedSchedule.schedule_id,
);
setSchedules(remaining);
if (schedule?.schedule_id === selectedSchedule.schedule_id) {
setSchedule(null);
clearPositionDrafts();
setSelectedNodeId(null);
setSelectedEdgeId(null);
if (remaining[0]) {
setSchedule(await api.getSchedule(remaining[0].schedule_id));
}
}
onNotify({ tone: "success", message: "调度方案已删除" });
} catch (error) {
await handleError(error, "删除调度失败");
} finally {
setBusy(null);
}
};
const renameSchedule = async (target: Schedule): Promise<void> => {
if (busy) return;
setContextMenu(null);
const scheduleName = window.prompt("请输入新的调度方案名称", target.schedule_name)?.trim();
if (!scheduleName || scheduleName === target.schedule_name) return;
setBusy("rename-schedule");
try {
const updated = await api.updateSchedule(target.schedule_id, {
workflow_version: target.workflow_version,
schedule_name: scheduleName,
});
setSchedules((current) => current.map((item) => (
item.schedule_id === updated.schedule_id
? { ...updated, nodes: [], edges: [] }
: item
)));
if (schedule?.schedule_id === updated.schedule_id) setSchedule(updated);
onNotify({ tone: "success", message: "调度方案已改名" });
} catch (error) {
await handleError(error, "调度方案改名失败");
} finally {
setBusy(null);
}
};
const removeArtifact = async (
artifact: ScheduleArtifact,
): Promise<void> => {
if (busy) return;
setContextMenu(null);
if (
!window.confirm(
`确定将"${artifact.script_name} ${artifact.version_label}"移出调度列表吗?\n`
+ "稳定版本本身和历史运行记录不会被删除。",
)
) return;
setBusy("delete-artifact");
try {
await api.hideScheduleArtifact(artifact.versions_id);
setArtifacts((current) => current.filter(
(item) => item.versions_id !== artifact.versions_id,
));
onNotify({
tone: "success",
message: "已移出调度列表,稳定版本和历史记录保持不变",
});
} catch (error) {
await handleError(error, "移出调度列表失败");
} finally {
setBusy(null);
}
};
const saveSchedule = async (): Promise<void> => {
if (!schedule || busy) return;
const maxConcurrency = Number(scheduleForm.maxConcurrency);
if (!scheduleForm.scheduleName.trim()) {
onNotify({ tone: "error", message: "调度名称不能为空" });
return;
}
if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1) {
onNotify({ tone: "error", message: "最大并发数必须是正整数" });
return;
}
setBusy("save-schedule");
let updated = useSchedulesStore.getState().schedule ?? schedule;
try {
for (const [nodeId, position] of Object.entries(
positionDraftsRef.current,
)) {
updated = await api.updateScheduleNode(updated.schedule_id, nodeId, {
workflow_version: updated.workflow_version,
position_x: position.position_x,
position_y: position.position_y,
});
}
updated = await api.updateSchedule(updated.schedule_id, {
workflow_version: updated.workflow_version,
schedule_name: scheduleForm.scheduleName.trim(),
description: scheduleForm.description.trim() || null,
trigger_type: scheduleForm.triggerType,
cron_expression: scheduleForm.triggerType === "cron"
? scheduleForm.cronExpression.trim()
: null,
timezone: scheduleForm.timezone.trim(),
enabled: scheduleForm.enabled,
max_concurrency: maxConcurrency,
failure_policy: scheduleForm.failurePolicy,
});
clearPositionDrafts();
setSchedule(updated);
setSchedules((current) => current.map((item) => (
item.schedule_id === updated.schedule_id
? { ...updated, nodes: [], edges: [] }
: item
)));
onNotify({ tone: "success", message: "调度配置已保存" });
} catch (error) {
const localDraft = applyPositionDrafts(updated);
setSchedule(localDraft);
await handleError(error, "调度配置保存失败");
} finally {
setBusy(null);
}
};
const runCronPreview = async (): Promise<void> => {
if (scheduleForm.triggerType !== "cron") return;
if (busy) return;
setBusy("cron-preview");
try {
const result = await api.previewCron({
cron_expression: scheduleForm.cronExpression.trim(),
timezone: scheduleForm.timezone.trim(),
count: 5,
});
setCronResult(result);
onNotify({ tone: "success", message: "Cron 表达式校验通过" });
} catch (error) {
setCronResult(null);
await handleError(error, "Cron 预览失败");
} finally {
setBusy(null);
}
};
const runNow = async (): Promise<void> => {
if (!schedule || busy) return;
if (positionDraftCount > 0) {
onNotify({
tone: "info",
message: "还有节点位置未保存,请先点击'保存配置'再运行",
});
return;
}
if (!schedule.dag_validation.valid || schedule.nodes.length === 0) {
onNotify({
tone: "error",
message: "当前调度必须包含有效的非空 DAG 才能运行",
});
return;
}
setBusy("run-now");
try {
const created = await api.runScheduleNow(schedule.schedule_id);
setRuns((current) => [
created,
...current.filter((item) => item.run_id !== created.run_id),
].slice(0, 20));
setSchedule((current) => (
current ? { ...current, last_run_at: created.queued_at } : current
));
setSchedules((current) => current.map((item) => (
item.schedule_id === schedule.schedule_id
? { ...item, last_run_at: created.queued_at }
: item
)));
onNotify({
tone: "success",
message: `运行 ${created.run_id.slice(-8)} 已进入队列`,
});
} catch (error) {
await handleError(error, "立即运行失败");
} finally {
setBusy(null);
}
};
const addArtifactAt = async (
artifact: ScheduleArtifact,
positionX: number,
positionY: number,
): Promise<void> => {
if (!schedule) {
onNotify({ tone: "info", message: "请先新建或选择一个调度方案" });
return;
}
const nodeKey = artifactNodeKey(artifact, schedule);
const updated = await withMutation(
"add-node",
() => api.createScheduleNode(schedule.schedule_id, {
workflow_version: schedule.workflow_version,
node_key: nodeKey,
node_name: artifact.script_name,
versions_id: artifact.versions_id,
timeout_seconds: 600,
retry_count: 0,
retry_interval_sec: 5,
position_x: Math.max(20, Math.round(positionX)),
position_y: Math.max(20, Math.round(positionY)),
arguments_json: {},
env_refs_json: {},
}),
`${artifact.script_name} 已加入画布`,
);
if (updated) {
const created = updated.nodes.find((item) => item.node_key === nodeKey);
setSelectedNodeId(created?.node_id ?? null);
}
};
const onCanvasDrop = (event: DragEvent<HTMLDivElement>): void => {
event.preventDefault();
const versionsId = event.dataTransfer.getData(ARTIFACT_MIME)
|| event.dataTransfer.getData("text/plain");
const artifact = artifacts.find((item) => item.versions_id === versionsId);
if (!artifact || !canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const positionX = event.clientX - rect.left
+ canvasRef.current.scrollLeft - NODE_WIDTH / 2;
const positionY = event.clientY - rect.top
+ canvasRef.current.scrollTop - NODE_HEIGHT / 2;
void addArtifactAt(artifact, positionX, positionY);
};
const startNodeDrag = (
event: ReactPointerEvent<HTMLDivElement>,
node: ScheduleNode,
): void => {
if (event.button !== 0 || busy || linkSourceId) return;
const target = event.target as HTMLElement;
if (target.closest("button")) return;
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = {
nodeId: node.node_id,
pointerId: event.pointerId,
startClientX: event.clientX,
startClientY: event.clientY,
originX: node.position_x,
originY: node.position_y,
moved: false,
};
setSelectedNodeId(node.node_id);
setSelectedEdgeId(null);
};
const moveNode = (
event: ReactPointerEvent<HTMLDivElement>,
): void => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
const deltaX = event.clientX - drag.startClientX;
const deltaY = event.clientY - drag.startClientY;
if (Math.abs(deltaX) + Math.abs(deltaY) > 3) drag.moved = true;
setSchedule((current) => {
if (!current) return current;
return {
...current,
nodes: current.nodes.map((item) => (
item.node_id === drag.nodeId
? {
...item,
position_x: Math.max(10, drag.originX + deltaX),
position_y: Math.max(10, drag.originY + deltaY),
}
: item
)),
};
});
};
const finishNodeDrag = (
event: ReactPointerEvent<HTMLDivElement>,
): void => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
dragRef.current = null;
if (!drag.moved) return;
const current = useSchedulesStore.getState().schedule;
const node = current?.nodes.find((item) => item.node_id === drag.nodeId);
if (!current || !node) return;
const position = {
position_x: Math.round(node.position_x),
position_y: Math.round(node.position_y),
};
positionDraftsRef.current = {
...positionDraftsRef.current,
[node.node_id]: position,
};
setPositionDraftCount(Object.keys(positionDraftsRef.current).length);
setSchedule((value) => {
if (!value) return value;
return {
...value,
nodes: value.nodes.map((item) => (
item.node_id === node.node_id ? { ...item, ...position } : item
)),
};
});
};
const connectTo = async (targetNodeId: string): Promise<void> => {
if (!schedule || !linkSourceId || busy) return;
if (linkSourceId === targetNodeId) {
setLinkSourceId(null);
onNotify({ tone: "info", message: "已取消连线" });
return;
}
const sourceId = linkSourceId;
setLinkSourceId(null);
await withMutation(
"create-edge",
() => api.createScheduleEdge(schedule.schedule_id, {
workflow_version: schedule.workflow_version,
source_node_id: sourceId,
target_node_id: targetNodeId,
}),
"节点连线已创建",
);
};
const saveNode = async (): Promise<void> => {
if (!schedule || !selectedNode) return;
try {
const timeoutSeconds = Number(nodeForm.timeoutSeconds);
const retryCount = Number(nodeForm.retryCount);
const retryIntervalSec = Number(nodeForm.retryIntervalSec);
if (!nodeForm.nodeName.trim()) throw new Error("节点名称不能为空");
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1) {
throw new Error("超时时间必须是正整数");
}
if (!Number.isInteger(retryCount) || retryCount < 0) {
throw new Error("重试次数必须是非负整数");
}
if (!Number.isInteger(retryIntervalSec) || retryIntervalSec < 0) {
throw new Error("重试间隔必须是非负整数");
}
const argumentsJson = parseObject(nodeForm.argumentsJson, "运行参数");
const rawEnv = parseObject(nodeForm.envRefsJson, "环境引用");
const envRefsJson = Object.fromEntries(
Object.entries(rawEnv).map(([key, value]) => {
if (typeof value !== "string") {
throw new Error("环境引用的值必须是字符串");
}
return [key, value];
}),
);
await withMutation(
"save-node",
() => api.updateScheduleNode(schedule.schedule_id, selectedNode.node_id, {
workflow_version: schedule.workflow_version,
node_name: nodeForm.nodeName.trim(),
timeout_seconds: timeoutSeconds,
retry_count: retryCount,
retry_interval_sec: retryIntervalSec,
arguments_json: argumentsJson,
env_refs_json: envRefsJson,
}),
"节点配置已保存",
);
} catch (error) {
await handleError(error, "节点配置保存失败");
}
};
const removeNode = async (target?: ScheduleNode): Promise<void> => {
const node = target ?? selectedNode;
if (!schedule || !node || busy) return;
setContextMenu(null);
if (!window.confirm(`确定删除节点"${node.node_name}"吗?`)) return;
const updated = await withMutation(
"delete-node",
() => api.deleteScheduleNode(
schedule.schedule_id,
node.node_id,
schedule.workflow_version,
),
"节点已删除",
);
if (updated && selectedNodeId === node.node_id) setSelectedNodeId(null);
};
const removeEdge = async (target?: ScheduleEdge): Promise<void> => {
const edge = target ?? selectedEdge;
if (!schedule || !edge || busy) return;
setContextMenu(null);
const updated = await withMutation(
"delete-edge",
() => api.deleteScheduleEdge(
schedule.schedule_id,
edge.edge_id,
schedule.workflow_version,
),
"连线已删除",
);
if (updated && selectedEdgeId === edge.edge_id) setSelectedEdgeId(null);
};
const checkDag = async (): Promise<void> => {
if (!schedule || busy) return;
setBusy("validate");
try {
const result = await api.validateSchedule(schedule.schedule_id);
setSchedule((current) => current
? { ...current, dag_validation: result }
: current);
onNotify({
tone: result.valid ? "success" : "error",
message: result.valid
? `DAG 校验通过,共 ${result.node_count} 个节点`
: result.errors.map((item) => item.message).join(""),
});
} catch (error) {
await handleError(error, "DAG 校验失败");
} finally {
setBusy(null);
}
};
const filteredSchedules = useMemo(() => {
const keyword = scheduleKeyword.trim().toLowerCase();
return keyword
? schedules.filter((item) => item.schedule_name.toLowerCase().includes(keyword))
: schedules;
}, [schedules, scheduleKeyword]);
const filteredArtifacts = useMemo(() => {
const keyword = artifactKeyword.trim().toLowerCase();
return keyword
? artifacts.filter((item) => (
item.script_name.toLowerCase().includes(keyword)
|| item.version_label.toLowerCase().includes(keyword)
))
: artifacts;
}, [artifacts, artifactKeyword]);
return (
<section className="schedule-page">
<header className="schedule-toolbar">
<div>
<strong>图形化调度</strong>
<span>
{schedule
? `版本 ${schedule.workflow_version} · ${schedule.dag_validation.valid ? "DAG 有效" : "待校验"}${
positionDraftCount > 0
? ` · ${positionDraftCount} 个节点位置待保存`
: ""
}`
: "创建调度后开始编排"}
</span>
</div>
<div className="schedule-toolbar__actions">
<button type="button" onClick={openCreateScheduleDialog}>
<Icon name="plus" size={15} />新建
</button>
<button
className="schedule-action--primary"
type="button"
disabled={!schedule || Boolean(busy)}
onClick={() => void saveSchedule()}
>
<Icon name="check" size={15} />保存配置
</button>
<button
type="button"
disabled={!schedule || Boolean(busy)}
onClick={() => void checkDag()}
>
校验 DAG
</button>
<button
className="schedule-action--run"
type="button"
disabled={
!schedule
|| Boolean(busy)
|| !schedule.dag_validation.valid
|| schedule.nodes.length === 0
}
title={
positionDraftCount > 0
? "请先保存节点位置"
: "立即执行当前已保存的稳定版本 DAG"
}
onClick={() => void runNow()}
>
<Icon name="play" size={14} />立即运行
</button>
<button
className="schedule-action--danger"
type="button"
disabled={!schedule || Boolean(busy)}
onClick={() => void removeSchedule(schedule ?? undefined)}
>
删除
</button>
</div>
</header>
<div className="schedule-workbench">
<aside className="schedule-left">
<ScheduleList
schedules={filteredSchedules}
selectedScheduleId={schedule?.schedule_id ?? null}
keyword={scheduleKeyword}
loading={loading}
onSelect={chooseSchedule}
onContextMenu={(event, item) => openContextMenu(event as ReactMouseEvent<HTMLElement | SVGElement>, item ? { kind: "schedule", schedule: item } : { kind: "schedule-list" })}
onKeywordChange={setScheduleKeyword}
/>
<ArtifactList
artifacts={filteredArtifacts}
keyword={artifactKeyword}
onDrop={(artifact, x, y) => void addArtifactAt(artifact, x, y)}
onDoubleClick={(artifact) => void addArtifactAt(artifact, 90 + (schedule?.nodes.length ?? 0) * 245, 130)}
onContextMenu={(event, artifact) => openContextMenu(event as ReactMouseEvent<HTMLElement | SVGElement>, { kind: "artifact", artifact })}
onKeywordChange={setArtifactKeyword}
/>
</aside>
<main className="schedule-center">
<ScheduleCanvasHeader
linkSourceId={linkSourceId}
onCancelLink={() => setLinkSourceId(null)}
/>
<div
className={`schedule-canvas${linkSourceId ? " is-linking" : ""}`}
ref={canvasRef}
onDragOver={(event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}}
onDrop={onCanvasDrop}
onClick={(event) => {
if (event.target === event.currentTarget) {
setSelectedNodeId(null);
setSelectedEdgeId(null);
}
}}
>
<div
className="schedule-canvas__surface"
style={{ width: CANVAS_WIDTH, height: CANVAS_HEIGHT }}
>
{schedule && (
<svg
className="schedule-edges"
viewBox={`0 0 ${CANVAS_WIDTH} ${CANVAS_HEIGHT}`}
aria-label="调度连线"
>
<defs>
<marker
id="schedule-arrow"
markerWidth="8"
markerHeight="8"
refX="7"
refY="4"
orient="auto"
>
<path d="M0,0 L8,4 L0,8 Z" fill="#4e92db" />
</marker>
</defs>
{schedule.edges.map((edge) => {
const source = schedule.nodes.find(
(node) => node.node_id === edge.source_node_id,
);
const target = schedule.nodes.find(
(node) => node.node_id === edge.target_node_id,
);
if (!source || !target) return null;
const path = edgePath(source, target);
return (
<g
className={selectedEdgeId === edge.edge_id ? "is-selected" : ""}
key={edge.edge_id}
onClick={(event) => {
event.stopPropagation();
setSelectedEdgeId(edge.edge_id);
setSelectedNodeId(null);
}}
onContextMenu={(event) => openContextMenu(event, {
kind: "edge",
edge,
})}
>
<path className="schedule-edge-hit" d={path} />
<path
className="schedule-edge-line"
d={path}
markerEnd="url(#schedule-arrow)"
/>
</g>
);
})}
</svg>
)}
{schedule?.nodes.map((node) => (
<div
className={`schedule-node${
selectedNodeId === node.node_id ? " is-selected" : ""
}`}
key={node.node_id}
style={{
left: node.position_x,
top: node.position_y,
width: NODE_WIDTH,
height: NODE_HEIGHT,
}}
onPointerDown={(event) => startNodeDrag(event, node)}
onPointerMove={moveNode}
onPointerUp={finishNodeDrag}
onPointerCancel={finishNodeDrag}
onContextMenu={(event) => openContextMenu(event, {
kind: "node",
node,
})}
>
<button
className="schedule-node__port schedule-node__port--in"
type="button"
aria-label={`连接到${node.node_name}`}
title={linkSourceId ? "点击完成连线" : "输入端口"}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
void connectTo(node.node_id);
}}
/>
<div className="schedule-node__heading">
<span className={`schedule-node__type schedule-node__type--${node.version.script_type}`}>
<Icon
name={node.version.script_type === "notebook" ? "notebook" : "python"}
size={14}
/>
</span>
<strong>{node.node_name}</strong>
<button
type="button"
aria-label="删除节点"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
setSelectedNodeId(node.node_id);
setSelectedEdgeId(null);
}}
>
<Icon name="settings" size={14} />
</button>
</div>
<p>{node.version.script_name}</p>
<footer>
<span>{node.node_key}</span>
<b>{node.version.version_label}</b>
</footer>
<button
className={`schedule-node__port schedule-node__port--out${
linkSourceId === node.node_id ? " is-active" : ""
}`}
type="button"
aria-label={`从${node.node_name}开始连线`}
title="输出端口"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
setLinkSourceId((current) => (
current === node.node_id ? null : node.node_id
));
setSelectedNodeId(node.node_id);
setSelectedEdgeId(null);
}}
/>
</div>
))}
{!schedule ? (
<div className="schedule-canvas-empty">
<Icon name="schedule" size={34} />
<strong>还没有选中调度方案</strong>
<p>点击"新建"创建一个调度,然后拖入稳定版本脚本。</p>
<button type="button" onClick={openCreateScheduleDialog}>
<Icon name="plus" size={15} />新建调度
</button>
</div>
) : schedule.nodes.length === 0 ? (
<div className="schedule-canvas-empty">
<Icon name="release" size={34} />
<strong>从稳定版本开始编排</strong>
<p>把左侧脚本卡片拖到这里,或双击卡片快速加入。</p>
</div>
) : null}
</div>
</div>
<footer className="schedule-canvas-status">
<span className={schedule?.dag_validation.valid ? "is-valid" : ""}>
{schedule?.dag_validation.valid ? "✓ DAG 有效" : "○ DAG 待校验"}
</span>
<span>{schedule?.nodes.length ?? 0} 个节点</span>
<span>{schedule?.edges.length ?? 0} 条连线</span>
{selectedEdge && (
<button type="button" onClick={() => void removeEdge(selectedEdge)}>
删除选中连线
</button>
)}
</footer>
</main>
<aside className="schedule-right">
<div className="schedule-inspector-scroll">
{selectedNode ? (
<NodeInspector
node={selectedNode}
form={nodeForm}
busy={Boolean(busy)}
onChange={setNodeForm}
onSave={() => void saveNode()}
onDelete={() => void removeNode(selectedNode)}
/>
) : (
<ScheduleInspector
schedule={schedule}
form={scheduleForm}
cronResult={cronResult}
busy={Boolean(busy)}
onChange={setScheduleForm}
onPreview={() => void runCronPreview()}
onSave={() => void saveSchedule()}
/>
)}
</div>
<RunHistory
runs={runs}
nodes={schedule?.nodes ?? []}
loading={runsLoading}
disabled={!schedule || Boolean(busy)}
onRefresh={() => {
if (schedule) void refreshRuns(schedule.schedule_id, true);
}}
/>
</aside>
</div>
{contextMenu && (
<div
className="schedule-context-menu"
role="menu"
style={{ left: contextMenu.x, top: contextMenu.y }}
onPointerDown={(event) => event.stopPropagation()}
>
{contextMenu.kind === "schedule-list" && (
<button
className="schedule-context-menu__action"
type="button"
role="menuitem"
onClick={openCreateScheduleDialog}
>
<Icon name="plus" size={14} />
新建调度方案
</button>
)}
{contextMenu.kind === "schedule" && (
<>
<button
className="schedule-context-menu__action"
type="button"
role="menuitem"
onClick={openCreateScheduleDialog}
>
<Icon name="plus" size={14} />
新建调度方案
</button>
<button
className="schedule-context-menu__action"
type="button"
role="menuitem"
onClick={() => void renameSchedule(contextMenu.schedule)}
>
<Icon name="settings" size={14} />
改名
</button>
<button
type="button"
role="menuitem"
onClick={() => void removeSchedule(contextMenu.schedule)}
>
<Icon name="close" size={14} />
删除调度方案
</button>
</>
)}
{contextMenu.kind === "artifact" && (
<button
type="button"
role="menuitem"
onClick={() => void removeArtifact(contextMenu.artifact)}
>
<Icon name="close" size={14} />
移出调度列表
</button>
)}
{contextMenu.kind === "node" && (
<button
type="button"
role="menuitem"
onClick={() => void removeNode(contextMenu.node)}
>
<Icon name="close" size={14} />
删除画布节点
</button>
)}
{contextMenu.kind === "edge" && (
<button
type="button"
role="menuitem"
onClick={() => void removeEdge(contextMenu.edge)}
>
<Icon name="close" size={14} />
删除画布连线
</button>
)}
</div>
)}
{createDialogOpen && (
<div className="modal-backdrop" role="presentation">
<section
className="modal modal--compact"
role="dialog"
aria-modal="true"
aria-labelledby="create-schedule-title"
>
<div className="modal__header">
<div>
<span className="modal__eyebrow">SCHEDULE</span>
<h2 id="create-schedule-title">新建调度方案</h2>
</div>
<button
className="icon-button"
type="button"
aria-label="关闭"
disabled={Boolean(busy)}
onClick={() => setCreateDialogOpen(false)}
>
<Icon name="close" />
</button>
</div>
<form onSubmit={(event) => void addSchedule(event)}>
<label className="form-field">
<span>调度方案名称</span>
<input
autoFocus
maxLength={255}
placeholder="请输入调度方案名称"
value={newScheduleName}
onChange={(event) => setNewScheduleName(event.target.value)}
onFocus={(event) => event.currentTarget.select()}
/>
</label>
<div className="modal__footer">
<button
className="secondary-button"
type="button"
disabled={Boolean(busy)}
onClick={() => setCreateDialogOpen(false)}
>
取消
</button>
<button
className="primary-button"
type="submit"
disabled={Boolean(busy) || !newScheduleName.trim()}
>
{busy === "create-schedule"
? <span className="button-spinner" />
: <Icon name="plus" size={15} />}
{busy === "create-schedule" ? "正在创建…" : "创建调度"}
</button>
</div>
</form>
</section>
</div>
)}
{busy && (
<div className="schedule-busy" aria-live="polite">
<span />
{busy === "run-now" ? "正在创建运行…" : "正在同步调度配置…"}
</div>
)}
</section>
);
}
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>
<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>
);
}
function ScheduleInspector({
schedule,
form,
cronResult,
busy,
onChange,
onPreview,
onSave,
}: {
schedule: Schedule | null;
form: ScheduleForm;
cronResult: CronPreview | null;
busy: boolean;
onChange: (form: ScheduleForm) => void;
onPreview: () => void;
onSave: () => void;
}) {
if (!schedule) {
return (
<div className="schedule-inspector-empty">
<Icon name="settings" size={28} />
<strong>调度属性</strong>
<p>选中调度方案后可配置触发方式、Cron 和执行策略。</p>
</div>
);
}
return (
<div className="schedule-inspector">
<section>
<h3>基本信息</h3>
<label>
<span>调度名称</span>
<input
value={form.scheduleName}
onChange={(event) => onChange({
...form,
scheduleName: event.target.value,
})}
/>
</label>
<label>
<span>说明</span>
<textarea
rows={2}
value={form.description}
onChange={(event) => onChange({
...form,
description: event.target.value,
})}
/>
</label>
<label className="schedule-switch-row">
<span>
<b>启用调度</b>
<small>DAG 有效后才能启用</small>
</span>
<input
type="checkbox"
checked={form.enabled}
onChange={(event) => onChange({
...form,
enabled: event.target.checked,
})}
/>
</label>
</section>
<section>
<h3>触发器</h3>
<label>
<span>触发方式</span>
<select
value={form.triggerType}
onChange={(event) => onChange({
...form,
triggerType: event.target.value as ScheduleForm["triggerType"],
enabled: event.target.value === "cron" ? form.enabled : false,
})}
>
<option value="manual">手动触发</option>
<option value="cron">Cron 定时</option>
<option value="api">API 触发</option>
</select>
</label>
{form.triggerType === "cron" && (
<>
<label>
<span>Cron(分 周)</span>
<input
value={form.cronExpression}
onChange={(event) => onChange({
...form,
cronExpression: event.target.value,
})}
/>
</label>
<label>
<span>时区</span>
<input
value={form.timezone}
onChange={(event) => onChange({
...form,
timezone: event.target.value,
})}
/>
</label>
<button
className="inspector-secondary-button"
type="button"
disabled={busy}
onClick={onPreview}
>
预览未来 5
</button>
{cronResult && (
<ol className="cron-preview-list">
{cronResult.occurrences.map((item) => (
<li key={item.utc_time}>
{new Date(item.local_time).toLocaleString("zh-CN", {
hour12: false,
})}
</li>
))}
</ol>
)}
</>
)}
</section>
<section>
<h3>执行策略</h3>
<div className="inspector-grid">
<label>
<span>最大并发</span>
<input
type="number"
min="1"
max="64"
value={form.maxConcurrency}
onChange={(event) => onChange({
...form,
maxConcurrency: event.target.value,
})}
/>
</label>
<label>
<span>失败策略</span>
<select
value={form.failurePolicy}
onChange={(event) => onChange({
...form,
failurePolicy: event.target.value as "stop" | "continue",
})}
>
<option value="stop">停止后续</option>
<option value="continue">继续执行</option>
</select>
</label>
</div>
<div className="schedule-next-run">
<span>下一次执行</span>
<strong>{formatTime(schedule.next_run_at)}</strong>
</div>
</section>
<button
className="inspector-primary-button"
type="button"
disabled={busy}
onClick={onSave}
>
保存调度属性
</button>
</div>
);
}
function NodeInspector({
node,
form,
busy,
onChange,
onSave,
onDelete,
}: {
node: ScheduleNode;
form: NodeForm;
busy: boolean;
onChange: (form: NodeForm) => void;
onSave: () => void;
onDelete: () => void;
}) {
return (
<div className="schedule-inspector">
<section className="node-inspector-title">
<span className={`artifact-card__icon artifact-card__icon--${node.version.script_type}`}>
<Icon
name={node.version.script_type === "notebook" ? "notebook" : "python"}
size={17}
/>
</span>
<div>
<small>节点配置</small>
<strong>{node.node_key}</strong>
</div>
</section>
<section>
<h3>稳定版本</h3>
<div className="version-readonly">
<strong>{node.version.script_name}</strong>
<span>{node.version.version_label}</span>
<small>versions_id: {node.versions_id}</small>
<small>SHA-256: {shortHash(node.version.content_hash)}</small>
</div>
</section>
<section>
<h3>节点信息</h3>
<label>
<span>节点名称</span>
<input
value={form.nodeName}
onChange={(event) => onChange({
...form,
nodeName: event.target.value,
})}
/>
</label>
<div className="inspector-grid">
<label>
<span>超时(秒)</span>
<input
type="number"
min="1"
value={form.timeoutSeconds}
onChange={(event) => onChange({
...form,
timeoutSeconds: event.target.value,
})}
/>
</label>
<label>
<span>重试次数</span>
<input
type="number"
min="0"
max="10"
value={form.retryCount}
onChange={(event) => onChange({
...form,
retryCount: event.target.value,
})}
/>
</label>
</div>
<label>
<span>重试间隔(秒)</span>
<input
type="number"
min="0"
value={form.retryIntervalSec}
onChange={(event) => onChange({
...form,
retryIntervalSec: event.target.value,
})}
/>
</label>
</section>
<section>
<h3>运行参数</h3>
<label>
<span>arguments_json</span>
<textarea
className="json-editor"
rows={5}
spellCheck={false}
value={form.argumentsJson}
onChange={(event) => onChange({
...form,
argumentsJson: event.target.value,
})}
/>
</label>
<label>
<span>env_refs_json</span>
<textarea
className="json-editor"
rows={4}
spellCheck={false}
value={form.envRefsJson}
onChange={(event) => onChange({
...form,
envRefsJson: event.target.value,
})}
/>
</label>
</section>
<div className="node-inspector-actions">
<button
className="inspector-primary-button"
type="button"
disabled={busy}
onClick={onSave}
>
保存节点
</button>
<button
className="inspector-danger-button"
type="button"
disabled={busy}
onClick={onDelete}
>
删除节点
</button>
</div>
</div>
);
}
function withSuppressedError(action: () => Promise<void>): void {
void action().catch(() => undefined);
}