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 = { queued: "排队中", running: "运行中", succeeded: "成功", failed: "失败", cancelled: "已取消", timed_out: "已超时", }; const NODE_STATUS_LABELS: Record = { ...RUN_STATUS_LABELS, skipped: "已跳过", }; const TRIGGER_TYPE_LABELS: Record = { 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 { 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; } 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(null); const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [linkSourceId, setLinkSourceId] = useState(null); const [scheduleKeyword, setScheduleKeyword] = useState(""); const [artifactKeyword, setArtifactKeyword] = useState(""); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [newScheduleName, setNewScheduleName] = useState(""); const [contextMenu, setContextMenu] = useState(null); const [scheduleForm, setScheduleForm] = useState(EMPTY_SCHEDULE_FORM); const [nodeForm, setNodeForm] = useState(EMPTY_NODE_FORM); const [cronResult, setCronResult] = useState(null); const [runs, setRuns] = useState([]); const [runsLoading, setRunsLoading] = useState(false); const canvasRef = useRef(null); const dragRef = useRef(null); const positionDraftsRef = useRef>({}); 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, 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 => { 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 => { 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 => { 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, successMessage: string, ): Promise => { 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 => { 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): Promise => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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): 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, 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, ): 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, ): 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 => { 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 => { 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 => { 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 => { 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 => { 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 (
图形化调度 {schedule ? `版本 ${schedule.workflow_version} · ${schedule.dag_validation.valid ? "DAG 有效" : "待校验"}${ positionDraftCount > 0 ? ` · ${positionDraftCount} 个节点位置待保存` : "" }` : "创建调度后开始编排"}
setLinkSourceId(null)} />
{ event.preventDefault(); event.dataTransfer.dropEffect = "copy"; }} onDrop={onCanvasDrop} onClick={(event) => { if (event.target === event.currentTarget) { setSelectedNodeId(null); setSelectedEdgeId(null); } }} >
{schedule && ( {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 ( { event.stopPropagation(); setSelectedEdgeId(edge.edge_id); setSelectedNodeId(null); }} onContextMenu={(event) => openContextMenu(event, { kind: "edge", edge, })} > ); })} )} {schedule?.nodes.map((node) => (
startNodeDrag(event, node)} onPointerMove={moveNode} onPointerUp={finishNodeDrag} onPointerCancel={finishNodeDrag} onContextMenu={(event) => openContextMenu(event, { kind: "node", node, })} >

{node.version.script_name}

{node.node_key} {node.version.version_label}
))} {!schedule ? (
还没有选中调度方案

点击"新建"创建一个调度,然后拖入稳定版本脚本。

) : schedule.nodes.length === 0 ? (
从稳定版本开始编排

把左侧脚本卡片拖到这里,或双击卡片快速加入。

) : null}
{schedule?.dag_validation.valid ? "✓ DAG 有效" : "○ DAG 待校验"} {schedule?.nodes.length ?? 0} 个节点 {schedule?.edges.length ?? 0} 条连线 {selectedEdge && ( )}
{contextMenu && (
event.stopPropagation()} > {contextMenu.kind === "schedule-list" && ( )} {contextMenu.kind === "schedule" && ( <> )} {contextMenu.kind === "artifact" && ( )} {contextMenu.kind === "node" && ( )} {contextMenu.kind === "edge" && ( )}
)} {createDialogOpen && (
SCHEDULE

新建调度方案

void addSchedule(event)}>
)} {busy && (
{busy === "run-now" ? "正在创建运行…" : "正在同步调度配置…"}
)}
); } function RunHistory({ runs, nodes, loading, disabled, onRefresh, }: { runs: ScheduleRunSummary[]; nodes: ScheduleNode[]; loading: boolean; disabled: boolean; onRefresh: () => void; }) { const api = useApi(); const [expandedRunId, setExpandedRunId] = useState(null); const [runDetails, setRunDetails] = useState>({}); const [detailLoadingId, setDetailLoadingId] = useState(null); const [detailErrors, setDetailErrors] = useState>({}); const [openLogNodeRunIds, setOpenLogNodeRunIds] = useState>( () => new Set(), ); const [logStates, setLogStates] = useState>({}); const [artifactBusyKey, setArtifactBusyKey] = useState(null); const [artifactErrors, setArtifactErrors] = useState>({}); 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 => { 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 => { 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 => { 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 (
运行记录 {runs.length}
{loading && runs.length === 0 ? (

正在加载运行记录…

) : runs.length === 0 ? (

点击右上角“立即运行”后,这里会显示状态和耗时。

) : ( runs.map((run) => { const expanded = expandedRunId === run.run_id; const detail = runDetails[run.run_id]; return (
{RUN_STATUS_LABELS[run.run_status]} {formatTime(run.queued_at)} · {formatDuration(run.duration_ms)} {run.error_message && {run.error_message}} {run.run_id.slice(-8)}
{expanded && ( <>
{detailLoadingId === run.run_id && !detail ? (

正在加载运行详情…

) : detailErrors[run.run_id] ? (

{detailErrors[run.run_id]}

) : detail ? ( <>
触发方式
{TRIGGER_TYPE_LABELS[detail.trigger_type]}
工作流版本
v{detail.workflow_version}
开始时间
{formatTime(detail.started_at)}
完成时间
{formatTime(detail.finished_at)}
{(detail.error_code || detail.error_message) && (
{detail.error_code && {detail.error_code}} {detail.error_message &&

{detail.error_message}

}
)}
{detail.node_runs.length === 0 ? (

节点尚未开始执行

) : 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 (
{node?.node_name ?? nodeRun.node_id.slice(-8)} {NODE_STATUS_LABELS[nodeRun.node_status]}
第 {nodeRun.attempt_no} 次 {formatDuration(nodeRun.duration_ms)} 退出码 {nodeRun.exit_code ?? "—"}
{nodeRun.message &&

{nodeRun.message}

}
{artifactErrors[nodeRun.node_run_id] && (

{artifactErrors[nodeRun.node_run_id]}

)} {logOpen && (
{logState?.fileName && (
{logState.fileName} {formatSize(new TextEncoder().encode(logState.content ?? "").length)}
)} {logState?.error ? (

{logState.error}

) : (
{logState?.content ?? "正在读取日志…"}
)}
)}
); })}
) : null}
)} ); }) )} ); } 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 (
调度属性

选中调度方案后可配置触发方式、Cron 和执行策略。

); } return (

基本信息