Files
model-platform/frontend/app/features/schedules/SchedulePage.tsx
T
2026-07-31 17:22:25 +08:00

1939 lines
62 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/Icon";
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: "已超时",
};
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 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 [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]);
const hasActiveRuns = runs.some(
(item) => item.run_status === "queued" || item.run_status === "running",
);
useEffect(() => {
const scheduleId = schedule?.schedule_id;
if (!scheduleId || !hasActiveRuns) return;
const timer = window.setInterval(() => {
void refreshRuns(scheduleId);
}, 1500);
return () => window.clearInterval(timer);
}, [schedule?.schedule_id, hasActiveRuns]);
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 onArtifactDragStart = (
event: DragEvent<HTMLDivElement>,
artifact: ScheduleArtifact,
): void => {
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.setData(ARTIFACT_MIME, artifact.versions_id);
event.dataTransfer.setData("text/plain", artifact.versions_id);
};
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">
<section className="schedule-panel schedule-list-panel">
<div
className="schedule-panel__title"
onContextMenu={(event) => openContextMenu(event, { kind: "schedule-list" })}
>
<div><strong>调度方案</strong><span>{schedules.length}</span></div>
</div>
<label className="schedule-search">
<Icon name="search" size={15} />
<input
value={scheduleKeyword}
placeholder="搜索调度名称"
onChange={(event) => setScheduleKeyword(event.target.value)}
/>
</label>
<div
className="schedule-list"
onContextMenu={(event) => openContextMenu(event, { kind: "schedule-list" })}
>
{loading ? (
<p className="schedule-empty">正在加载调度方案…</p>
) : filteredSchedules.length ? (
filteredSchedules.map((item) => (
<button
className={`schedule-card${
schedule?.schedule_id === item.schedule_id
? " schedule-card--active"
: ""
}`}
key={item.schedule_id}
type="button"
onClick={() => void chooseSchedule(item.schedule_id)}
onContextMenu={(event) => openContextMenu(event, {
kind: "schedule",
schedule: item,
})}
>
<span className={`schedule-status${item.enabled ? " is-enabled" : ""}`} />
<span>
<strong>{item.schedule_name}</strong>
<small>
{item.node_count} 个节点 · {item.enabled ? "已启用" : "未启用"}
</small>
</span>
</button>
))
) : (
<p className="schedule-empty">暂无调度方案</p>
)}
</div>
</section>
<section className="schedule-panel schedule-artifacts-panel">
<div className="schedule-panel__title">
<div><strong>稳定版本脚本</strong><span>{artifacts.length}</span></div>
</div>
<label className="schedule-search">
<Icon name="search" size={15} />
<input
value={artifactKeyword}
placeholder="搜索稳定版本"
onChange={(event) => setArtifactKeyword(event.target.value)}
/>
</label>
<p className="artifact-tip">拖动卡片到画布,节点将保存 versions_id</p>
<div className="artifact-list">
{filteredArtifacts.length ? filteredArtifacts.map((artifact) => (
<div
className="artifact-card"
draggable
key={artifact.versions_id}
onDragStart={(event) => onArtifactDragStart(event, artifact)}
onContextMenu={(event) => openContextMenu(event, {
kind: "artifact",
artifact,
})}
onDoubleClick={() => void addArtifactAt(
artifact,
90 + (schedule?.nodes.length ?? 0) * 245,
130,
)}
>
<span className={`artifact-card__icon artifact-card__icon--${artifact.script_type}`}>
<Icon
name={artifact.script_type === "notebook" ? "notebook" : "python"}
size={17}
/>
</span>
<span>
<strong>{artifact.script_name}</strong>
<small>
{artifact.version_label} · {shortHash(artifact.content_hash)}
</small>
</span>
</div>
)) : (
<p className="schedule-empty">
暂无稳定版本,请先在“构建脚本”中发布
</p>
)}
</div>
</section>
</aside>
<main className="schedule-center">
<div className="schedule-canvas-title">
<div>
<strong>流程画布</strong>
<span>
拖入稳定版本;点击节点右侧圆点,再点击目标左侧圆点完成连线
</span>
</div>
{linkSourceId && (
<button type="button" onClick={() => setLinkSourceId(null)}>
取消连线
</button>
)}
</div>
<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>
);
}
function RunHistory({
runs,
loading,
disabled,
onRefresh,
}: {
runs: ScheduleRunSummary[];
loading: boolean;
disabled: boolean;
onRefresh: () => void;
}) {
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) => (
<article className="schedule-run-card" key={run.run_id}>
<span className={`run-status-dot is-${run.run_status}`} />
<div>
<strong>{RUN_STATUS_LABELS[run.run_status]}</strong>
<small>
{formatTime(run.queued_at)} · {formatDuration(run.duration_ms)}
</small>
{run.error_message && <p>{run.error_message}</p>}
</div>
<code title={run.run_id}>{run.run_id.slice(-8)}</code>
</article>
))
)}
</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);
}