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

679 lines
26 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,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
type Schedule,
type ScheduleArtifact,
type ScheduleEdge,
type ScheduleNode,
} from "../../services/api";
import { useApi, useAuth } from "../../context/AuthContext";
import Icon from "../../components/common/Icon";
import { NodeInspector } from "./NodeInspector";
import { RunHistory } from "./RunHistory";
import { ScheduleInspector } from "./ScheduleInspector";
import { ScheduleList } from "./ScheduleList";
import { ArtifactList } from "./ArtifactList";
import { ScheduleCanvasHeader } from "./ScheduleCanvasHeader";
import { useCanvasNodeDrag } from "./hooks/useCanvasNodeDrag";
import { useContextMenuDismiss } from "./hooks/useContextMenuDismiss";
import {
EMPTY_NODE_FORM,
type ScheduleContextMenu,
} from "./state/schedulesStore";
import { useSchedulesStore } from "./state/schedulesStore";
import { edgePath, nodeToForm, scheduleToForm } from "./utils";
import "../../styles/schedule.css";
type Notice = {
tone: "success" | "error" | "info";
message: string;
};
type ScheduleContextMenuTarget =
| { kind: "schedule-list" }
| { kind: "schedule"; schedule: Schedule }
| { kind: "artifact"; artifact: ScheduleArtifact }
| { kind: "node"; node: ScheduleNode }
| { kind: "edge"; edge: ScheduleEdge };
const CANVAS_WIDTH = 1400;
const CANVAS_HEIGHT = 860;
const NODE_WIDTH = 218;
const NODE_HEIGHT = 104;
const ARTIFACT_MIME = "application/x-model-platform-version";
export default function SchedulePage({
onNotify,
onConnectionChange,
}: {
onNotify: (notice: Notice) => void;
onConnectionChange: (online: boolean) => void;
}) {
const { currentWorkspace } = useAuth();
const workspaceId = currentWorkspace?.workspace_id;
const api = useApi();
// 核心数据 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 = useSchedulesStore((s) => s.selectedNodeId);
const setSelectedNodeId = useSchedulesStore((s) => s.setSelectedNodeId);
const selectedEdgeId = useSchedulesStore((s) => s.selectedEdgeId);
const setSelectedEdgeId = useSchedulesStore((s) => s.setSelectedEdgeId);
const linkSourceId = useSchedulesStore((s) => s.linkSourceId);
const setLinkSourceId = useSchedulesStore((s) => s.setLinkSourceId);
const scheduleKeyword = useSchedulesStore((s) => s.scheduleKeyword);
const setScheduleKeyword = useSchedulesStore((s) => s.setScheduleKeyword);
const artifactKeyword = useSchedulesStore((s) => s.artifactKeyword);
const setArtifactKeyword = useSchedulesStore((s) => s.setArtifactKeyword);
const createDialogOpen = useSchedulesStore((s) => s.createDialogOpen);
const setCreateDialogOpen = useSchedulesStore((s) => s.setCreateDialogOpen);
const newScheduleName = useSchedulesStore((s) => s.newScheduleName);
const setNewScheduleName = useSchedulesStore((s) => s.setNewScheduleName);
const contextMenu = useSchedulesStore((s) => s.contextMenu);
const setContextMenu = useSchedulesStore((s) => s.setContextMenu);
const scheduleForm = useSchedulesStore((s) => s.scheduleForm);
const setScheduleForm = useSchedulesStore((s) => s.setScheduleForm);
const nodeForm = useSchedulesStore((s) => s.nodeForm);
const setNodeForm = useSchedulesStore((s) => s.setNodeForm);
const cronResult = useSchedulesStore((s) => s.cronResult);
const setCronResult = useSchedulesStore((s) => s.setCronResult);
const runs = useSchedulesStore((s) => s.runs);
const setRuns = useSchedulesStore((s) => s.setRuns);
const runsLoading = useSchedulesStore((s) => s.runsLoading);
const setRunsLoading = useSchedulesStore((s) => s.setRunsLoading);
const canvasRef = useRef<HTMLDivElement | null>(null);
const positionDraftCount = useSchedulesStore((state) => state.positionDraftCount);
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]);
useContextMenuDismiss();
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);
};
useEffect(() => {
if (!workspaceId || !api) return;
setSchedules([]);
setArtifacts([]);
setSchedule(null);
setRuns([]);
setRunsLoading(false);
void useSchedulesStore.getState().loadInitial();
}, [workspaceId, api, setSchedules, setArtifacts, setSchedule]);
// 进入调度页或切换调度方案后,主动加载该方案已经存在的运行记录。
// 原先只有「手动运行」和运行中的轮询会更新列表,因此从其他页面返回时会
// 一直停留在加载状态,直到产生新的运行记录。
useEffect(() => {
const scheduleId = schedule?.schedule_id;
if (!scheduleId || !api) {
setRuns([]);
setRunsLoading(false);
return;
}
void useSchedulesStore.getState().refreshRuns(scheduleId, true);
}, [schedule?.schedule_id, api, setRuns, setRunsLoading]);
useEffect(() => {
const scheduleId = schedule?.schedule_id;
if (!scheduleId || !api) return;
const hasActiveRun = runs.some(
(item) => item.run_status === "queued" || item.run_status === "running",
);
const isEnabledCron = schedule.trigger_type === "cron" && schedule.enabled;
// Cron 会由后端在未来某个整分钟创建新记录。即使当前没有运行中的
// 记录,也要持续刷新,才能让新一轮运行自动出现在右侧列表中。
if (!hasActiveRun && !isEnabledCron) return;
const timer = window.setInterval(() => {
void useSchedulesStore.getState().refreshRuns(scheduleId);
}, hasActiveRun ? 1500 : 3000);
return () => window.clearInterval(timer);
}, [schedule?.schedule_id, schedule?.trigger_type, schedule?.enabled, runs, api]);
// Form submit wrapper for create-schedule modal — keeps the FormEvent flow out of JSX.
const handleCreateScheduleSubmit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
void useSchedulesStore.getState().addSchedule(newScheduleName);
};
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 useSchedulesStore.getState().addArtifactAt(artifact, positionX, positionY);
};
const { startDrag, moveDrag, finishDrag } = useCanvasNodeDrag({ busy, linkSourceId });
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={() => useSchedulesStore.getState().openCreateDialog()}>
<Icon name="plus" size={15} />新建
</button>
<button
className="schedule-action--primary"
type="button"
disabled={!schedule || Boolean(busy)}
onClick={() => useSchedulesStore.getState().saveSchedule()}
>
<Icon name="check" size={15} />保存配置
</button>
<button
type="button"
disabled={!schedule || Boolean(busy)}
onClick={() => useSchedulesStore.getState().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={() => useSchedulesStore.getState().runNow()}
>
<Icon name="play" size={14} />立即运行
</button>
<button
className="schedule-action--danger"
type="button"
disabled={!schedule || Boolean(busy)}
onClick={() => useSchedulesStore.getState().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={(id) => useSchedulesStore.getState().chooseSchedule(id)}
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 useSchedulesStore.getState().addArtifactAt(artifact, x, y)}
onDoubleClick={(artifact) => void useSchedulesStore.getState().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) => {
// 画布实际内容位于 surface/SVG 子元素中,不能只比较 currentTarget
// 点击节点或连线时保留选择,点击其余空白位置则返回调度方案基本信息。
const target = event.target as Element;
if (
target.closest(".schedule-node") ||
target.matches(".schedule-edge-line, .schedule-edge-hit")
) return;
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) => startDrag(event, node)}
onPointerMove={moveDrag}
onPointerUp={finishDrag}
onPointerCancel={finishDrag}
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 useSchedulesStore.getState().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(
useSchedulesStore.getState().linkSourceId === 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={() => useSchedulesStore.getState().openCreateDialog()}>
<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={() => useSchedulesStore.getState().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={() => useSchedulesStore.getState().saveNode(selectedNode)}
onDelete={() => useSchedulesStore.getState().removeNode(selectedNode)}
/>
) : (
<ScheduleInspector
schedule={schedule}
form={scheduleForm}
cronResult={cronResult}
busy={Boolean(busy)}
onChange={setScheduleForm}
onPreview={() => useSchedulesStore.getState().runCronPreview()}
onSave={() => useSchedulesStore.getState().saveSchedule()}
/>
)}
</div>
<RunHistory
runs={runs}
nodes={schedule?.nodes ?? []}
loading={runsLoading}
disabled={!schedule || Boolean(busy)}
onRefresh={() => {
if (schedule) void useSchedulesStore.getState().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={() => useSchedulesStore.getState().openCreateDialog()}
>
<Icon name="plus" size={14} />
新建调度方案
</button>
)}
{contextMenu.kind === "schedule" && (
<>
<button
className="schedule-context-menu__action"
type="button"
role="menuitem"
onClick={() => useSchedulesStore.getState().openCreateDialog()}
>
<Icon name="plus" size={14} />
新建调度方案
</button>
<button
className="schedule-context-menu__action"
type="button"
role="menuitem"
onClick={() => useSchedulesStore.getState().renameSchedule(contextMenu.schedule)}
>
<Icon name="settings" size={14} />
改名
</button>
<button
type="button"
role="menuitem"
onClick={() => useSchedulesStore.getState().removeSchedule(contextMenu.schedule)}
>
<Icon name="close" size={14} />
删除调度方案
</button>
</>
)}
{contextMenu.kind === "artifact" && (
<button
type="button"
role="menuitem"
onClick={() => useSchedulesStore.getState().removeArtifact(contextMenu.artifact)}
>
<Icon name="close" size={14} />
移出调度列表
</button>
)}
{contextMenu.kind === "node" && (
<button
type="button"
role="menuitem"
onClick={() => useSchedulesStore.getState().removeNode(contextMenu.node)}
>
<Icon name="close" size={14} />
删除画布节点
</button>
)}
{contextMenu.kind === "edge" && (
<button
type="button"
role="menuitem"
onClick={() => useSchedulesStore.getState().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={handleCreateScheduleSubmit}>
<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>
);
}