727 lines
27 KiB
TypeScript
727 lines
27 KiB
TypeScript
import {
|
||
type DragEvent,
|
||
type FormEvent,
|
||
memo,
|
||
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";
|
||
|
||
/**
|
||
* 顶部状态文案里 “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]);
|
||
|
||
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={(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) => openScheduleContextMenu(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) => (
|
||
<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>
|
||
);
|
||
}
|