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

757 lines
28 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,
memo,
type MouseEvent as ReactMouseEvent,
useCallback,
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";
/**
* 顶部状态文案里 “N 个节点位置待保存” 那段抽出来,
* 让它独立订阅 positionDraftCount,避免每次 setPositionDraftCount 都让
* SchedulePage 整树重渲染。
*/
function PositionDraftBadge() {
const count = useSchedulesStore((s) => s.positionDraftCount);
if (count <= 0) return null;
return <> · {count} 个节点位置待保存</>;
}
/**
* “立即运行” 按钮的 disabled / title 都依赖 positionDraftCount,
* 抽成子组件独立订阅该字段。
*/
function RunNowButton({
schedule,
busy,
}: {
schedule: import("../../services/api").Schedule | null;
busy: string | null | undefined;
}) {
const positionDraftCount = useSchedulesStore((s) => s.positionDraftCount);
return (
<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>
);
}
/**
* 把右键菜单打开逻辑提到模块级,使 ScheduleEdge 这类 memo 化的子组件
* 也能直接调用,避免通过 props 传入每渲染都新建的箭头函数。
*/
function openScheduleContextMenu(
event: ReactMouseEvent<HTMLElement | SVGElement>,
target: ScheduleContextMenuTarget,
): void {
event.preventDefault();
event.stopPropagation();
const menuWidth = 176;
const menuHeight = target.kind === "schedule" ? 132 : 48;
useSchedulesStore.getState().setContextMenu({
...target,
x: Math.min(event.clientX, window.innerWidth - menuWidth - 8),
y: Math.min(event.clientY, window.innerHeight - menuHeight - 8),
} as ScheduleContextMenu);
}
/**
* 单条调度连线。React.memo 包裹后,selectedEdgeId / nodes 引用未变就不
* 重渲染,避免切换选中状态时所有边重算 edgePath。
*/
const ScheduleEdge = memo(function ScheduleEdge({
edge,
nodes,
selectedEdgeId,
}: {
edge: ScheduleEdge;
nodes: ScheduleNode[];
selectedEdgeId: string | null;
}) {
const path = useMemo(() => {
const source = nodes.find((node) => node.node_id === edge.source_node_id);
const target = nodes.find((node) => node.node_id === edge.target_node_id);
if (!source || !target) return null;
return edgePath(source, target);
}, [nodes, edge.source_node_id, edge.target_node_id]);
if (!path) return null;
return (
<g
className={selectedEdgeId === edge.edge_id ? "is-selected" : ""}
onClick={(event) => {
event.stopPropagation();
useSchedulesStore.getState().setSelectedEdgeId(edge.edge_id);
useSchedulesStore.getState().setSelectedNodeId(null);
}}
onContextMenu={(event) => openScheduleContextMenu(event, {
kind: "edge",
edge,
})}
>
<path className="schedule-edge-hit" d={path} />
<path
className="schedule-edge-line"
d={path}
markerEnd="url(#schedule-arrow)"
/>
</g>
);
});
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 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 setRuns = useSchedulesStore((s) => s.setRuns);
const setRunsLoading = useSchedulesStore((s) => s.setRunsLoading);
const canvasRef = useRef<HTMLDivElement | null>(null);
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();
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;
}
let timer: number | undefined;
let cancelled = false;
const tick = async (showLoading = false): Promise<void> => {
if (cancelled) return;
// 必须等待请求完成再判断状态;旧实现会在首个请求返回前看到空数组,
// 因而错误地停止轮询,导致运行记录必须手工刷新才出现。
await useSchedulesStore.getState().refreshRuns(scheduleId, showLoading);
if (cancelled || useSchedulesStore.getState().schedule?.schedule_id !== scheduleId) {
return;
}
const runs = useSchedulesStore.getState().runs;
const hasActiveRun = runs.some(
(item) => item.run_status === "queued" || item.run_status === "running",
);
// 页面停留期间持续刷新:运行中更快,空闲时较慢,既能自动显示 Cron
// 新记录,也不会因频繁请求影响其他调度页面操作。
timer = window.setTimeout(() => {
void tick();
}, hasActiveRun ? 1500 : 5000);
};
void tick(true);
return () => {
cancelled = true;
if (timer !== undefined) window.clearTimeout(timer);
};
}, [schedule?.schedule_id, api, setRuns, setRunsLoading]);
// 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]);
// 稳定传给 ArtifactList 的 3 个回调,让 memo 真正生效 —— 否则父级
// (SchedulePage) 任何 store 字段变化都会让卡片列表重新绑定拖拽事件,
// 在拖动/快速 hover 时放大体感卡顿。
const handleArtifactDrop = useCallback(
(artifact: ScheduleArtifact, x: number, y: number) => {
useSchedulesStore.getState().addArtifactAt(artifact, x, y);
},
[],
);
const handleArtifactDoubleClick = useCallback(
(artifact: ScheduleArtifact) => {
useSchedulesStore.getState().addArtifactAt(
artifact,
90 + (schedule?.nodes.length ?? 0) * 245,
130,
);
},
[schedule],
);
const handleArtifactContextMenu = useCallback(
(event: ReactMouseEvent, artifact: ScheduleArtifact) => {
openScheduleContextMenu(
event as ReactMouseEvent<HTMLElement | SVGElement>,
{ kind: "artifact", artifact },
);
},
[],
);
return (
<section className="schedule-page">
<header className="schedule-toolbar">
<div>
<strong>图形化调度</strong>
<span>
{schedule
? `版本 ${schedule.workflow_version} · ${schedule.dag_validation.valid ? "DAG 有效" : "待校验"}`
: "创建调度后开始编排"}
<PositionDraftBadge />
</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>
<RunNowButton schedule={schedule} busy={busy} />
<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) => openScheduleContextMenu(event as ReactMouseEvent<HTMLElement | SVGElement>, item ? { kind: "schedule", schedule: item } : { kind: "schedule-list" })}
onKeywordChange={setScheduleKeyword}
/>
<ArtifactList
artifacts={filteredArtifacts}
keyword={artifactKeyword}
onDrop={handleArtifactDrop}
onDoubleClick={handleArtifactDoubleClick}
onContextMenu={handleArtifactContextMenu}
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) => (
<ScheduleEdge
key={edge.edge_id}
edge={edge}
nodes={schedule.nodes}
selectedEdgeId={selectedEdgeId}
/>
))}
</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) => openScheduleContextMenu(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
nodes={schedule?.nodes ?? []}
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>
);
}