Files
model-platform/frontend/app/features/schedules/SchedulePage.tsx
T
2026-08-05 15:58:18 +08:00

1350 lines
45 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 ScheduleRunSummary,
} from "../../services/api";
import { useApi } from "../../context/AuthContext";
import Icon from "../../components/common/Icon";
import {
CANVAS_WIDTH,
CANVAS_HEIGHT,
NODE_WIDTH,
NODE_HEIGHT,
ARTIFACT_MIME,
EMPTY_SCHEDULE_FORM,
EMPTY_NODE_FORM,
} from "./constants";
import {
formatTime,
shortHash,
parseObject,
scheduleToForm,
nodeToForm,
artifactNodeKey,
edgePath,
withSuppressedError,
} from "./utils";
import { ScheduleList } from "./ScheduleList";
import { ArtifactList } from "./ArtifactList";
import { ScheduleCanvasHeader } from "./ScheduleCanvasHeader";
import { ScheduleInspector } from "./ScheduleInspector";
import { NodeInspector } from "./NodeInspector";
import { RunHistory } from "./RunHistory";
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 };
export default function SchedulePage({
onNotify,
onConnectionChange,
}: {
onNotify: (notice: Notice) => void;
onConnectionChange: (online: boolean) => void;
}) {
const [schedules, setSchedules] = useState<Schedule[]>([]);
const [artifacts, setArtifacts] = useState<ScheduleArtifact[]>([]);
const [schedule, setSchedule] = useState<Schedule | null>(null);
const scheduleRef = useRef<Schedule | null>(null);
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 [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
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(() => {
scheduleRef.current = schedule;
}, [schedule]);
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 (scheduleRef.current?.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
?? scheduleRef.current?.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;
};
}, []);
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 = scheduleRef.current?.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 = scheduleRef.current ?? 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);
scheduleRef.current = localDraft;
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;
const next = {
...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
)),
};
scheduleRef.current = next;
return next;
});
};
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 = scheduleRef.current;
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;
const next = {
...value,
nodes: value.nodes.map((item) => (
item.node_id === node.node_id ? { ...item, ...position } : item
)),
};
scheduleRef.current = next;
return next;
});
};
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}
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>
);
}