2189 lines
73 KiB
TypeScript
2189 lines
73 KiB
TypeScript
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<ScheduleRunSummary["run_status"], string> = {
|
||
queued: "排队中",
|
||
running: "运行中",
|
||
succeeded: "成功",
|
||
failed: "失败",
|
||
cancelled: "已取消",
|
||
timed_out: "已超时",
|
||
};
|
||
|
||
const NODE_STATUS_LABELS: Record<ScheduleNodeRun["node_status"], string> = {
|
||
...RUN_STATUS_LABELS,
|
||
skipped: "已跳过",
|
||
};
|
||
|
||
const TRIGGER_TYPE_LABELS: Record<ScheduleRunSummary["trigger_type"], string> = {
|
||
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<string, unknown> {
|
||
let value: unknown;
|
||
try {
|
||
value = JSON.parse(text);
|
||
} catch {
|
||
throw new Error(`${label}必须是合法 JSON`);
|
||
}
|
||
if (!value || Array.isArray(value) || typeof value !== "object") {
|
||
throw new Error(`${label}必须是 JSON 对象`);
|
||
}
|
||
return value as Record<string, unknown>;
|
||
}
|
||
|
||
function scheduleToForm(schedule: Schedule): ScheduleForm {
|
||
return {
|
||
scheduleName: schedule.schedule_name,
|
||
description: schedule.description ?? "",
|
||
triggerType: schedule.trigger_type,
|
||
cronExpression: schedule.cron_expression ?? "0 9 * * *",
|
||
timezone: schedule.timezone,
|
||
enabled: schedule.enabled,
|
||
maxConcurrency: String(schedule.max_concurrency),
|
||
failurePolicy: schedule.failure_policy,
|
||
};
|
||
}
|
||
|
||
function nodeToForm(node: ScheduleNode): NodeForm {
|
||
return {
|
||
nodeName: node.node_name,
|
||
timeoutSeconds: String(node.timeout_seconds),
|
||
retryCount: String(node.retry_count),
|
||
retryIntervalSec: String(node.retry_interval_sec),
|
||
argumentsJson: JSON.stringify(node.arguments_json ?? {}, null, 2),
|
||
envRefsJson: JSON.stringify(node.env_refs_json ?? {}, null, 2),
|
||
};
|
||
}
|
||
|
||
function artifactNodeKey(
|
||
artifact: ScheduleArtifact,
|
||
schedule: Schedule,
|
||
): string {
|
||
const ascii = artifact.script_name
|
||
.replace(/\.[^.]+$/, "")
|
||
.replace(/[^A-Za-z0-9_-]+/g, "_")
|
||
.replace(/^([^A-Za-z])/, "n_$1")
|
||
.replace(/^_+|_+$/g, "")
|
||
.slice(0, 48);
|
||
const base = ascii || `node_${artifact.versions_id.slice(-6).toLowerCase()}`;
|
||
const existing = new Set(schedule.nodes.map((item) => item.node_key));
|
||
if (!existing.has(base)) return base;
|
||
let index = 2;
|
||
while (existing.has(`${base}_${index}`)) index += 1;
|
||
return `${base}_${index}`.slice(0, 64);
|
||
}
|
||
|
||
function edgePath(
|
||
source: ScheduleNode,
|
||
target: ScheduleNode,
|
||
): string {
|
||
const x1 = source.position_x + NODE_WIDTH;
|
||
const y1 = source.position_y + NODE_HEIGHT / 2;
|
||
const x2 = target.position_x;
|
||
const y2 = target.position_y + NODE_HEIGHT / 2;
|
||
const curve = Math.max(70, Math.abs(x2 - x1) * 0.45);
|
||
return `M ${x1} ${y1} C ${x1 + curve} ${y1}, ${x2 - curve} ${y2}, ${x2} ${y2}`;
|
||
}
|
||
export default function SchedulePage({
|
||
onNotify,
|
||
onConnectionChange,
|
||
}: {
|
||
onNotify: (notice: Notice) => void;
|
||
onConnectionChange: (online: boolean) => void;
|
||
}) {
|
||
const { 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<string | null>(null);
|
||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||
const [linkSourceId, setLinkSourceId] = useState<string | null>(null);
|
||
const [scheduleKeyword, setScheduleKeyword] = useState("");
|
||
const [artifactKeyword, setArtifactKeyword] = useState("");
|
||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||
const [newScheduleName, setNewScheduleName] = useState("");
|
||
const [contextMenu, setContextMenu] = useState<ScheduleContextMenu | null>(null);
|
||
const [scheduleForm, setScheduleForm] = useState<ScheduleForm>(EMPTY_SCHEDULE_FORM);
|
||
const [nodeForm, setNodeForm] = useState<NodeForm>(EMPTY_NODE_FORM);
|
||
const [cronResult, setCronResult] = useState<CronPreview | null>(null);
|
||
const [runs, setRuns] = useState<ScheduleRunSummary[]>([]);
|
||
const [runsLoading, setRunsLoading] = useState(false);
|
||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||
const dragRef = useRef<DragState | null>(null);
|
||
const positionDraftCount = useSchedulesStore((state) => state.positionDraftCount);
|
||
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 openContextMenu = (
|
||
event: ReactMouseEvent<HTMLElement | SVGElement>,
|
||
target: ScheduleContextMenuTarget,
|
||
): void => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const menuWidth = 176;
|
||
const menuHeight = target.kind === "schedule" ? 132 : 48;
|
||
setContextMenu({
|
||
...target,
|
||
x: Math.min(event.clientX, window.innerWidth - menuWidth - 8),
|
||
y: Math.min(event.clientY, window.innerHeight - menuHeight - 8),
|
||
} as ScheduleContextMenu);
|
||
};
|
||
|
||
const applyPositionDrafts = (serverSchedule: Schedule): Schedule => {
|
||
const drafts = useSchedulesStore.getState().getPositionDrafts();
|
||
if (drafts.size === 0) return serverSchedule;
|
||
return {
|
||
...serverSchedule,
|
||
nodes: serverSchedule.nodes.map((node) => {
|
||
const draft = drafts.get(node.node_id);
|
||
return draft ? { ...node, ...draft } : node;
|
||
}),
|
||
};
|
||
};
|
||
|
||
const refreshRuns = async (
|
||
scheduleId: string,
|
||
showLoading = false,
|
||
): Promise<void> => {
|
||
if (showLoading) setRunsLoading(true);
|
||
try {
|
||
const items = await api.listScheduleRuns({
|
||
scheduleId,
|
||
limit: 20,
|
||
});
|
||
if (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<void> => {
|
||
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<void> => {
|
||
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<Schedule>,
|
||
successMessage: string,
|
||
): Promise<Schedule | null> => {
|
||
if (busy) return null;
|
||
setBusy(label);
|
||
try {
|
||
const serverUpdated = await action();
|
||
const validNodeIds = new Set(
|
||
serverUpdated.nodes.map((node) => node.node_id),
|
||
);
|
||
useSchedulesStore.getState().prunePositionDrafts(validNodeIds);
|
||
const updated = applyPositionDrafts(serverUpdated);
|
||
setSchedule(updated);
|
||
setSchedules((current) => {
|
||
const summary = { ...updated, nodes: [], edges: [] };
|
||
const index = current.findIndex(
|
||
(item) => item.schedule_id === updated.schedule_id,
|
||
);
|
||
if (index < 0) return [summary, ...current];
|
||
return current.map((item) => (
|
||
item.schedule_id === updated.schedule_id ? summary : item
|
||
));
|
||
});
|
||
onNotify({ tone: "success", message: successMessage });
|
||
return updated;
|
||
} catch (error) {
|
||
await handleError(error, `${successMessage}失败`);
|
||
return null;
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const chooseSchedule = async (scheduleId: string): Promise<void> => {
|
||
if (scheduleId === schedule?.schedule_id || busy) return;
|
||
setBusy("load-schedule");
|
||
useSchedulesStore.getState().clearAllPositionDrafts();
|
||
setSelectedNodeId(null);
|
||
setSelectedEdgeId(null);
|
||
setLinkSourceId(null);
|
||
setCronResult(null);
|
||
try {
|
||
setSchedule(await api.getSchedule(scheduleId));
|
||
onConnectionChange(true);
|
||
} catch (error) {
|
||
await handleError(error, "调度详情加载失败");
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const openCreateScheduleDialog = (): void => {
|
||
if (busy) return;
|
||
setContextMenu(null);
|
||
setNewScheduleName(`新建调度 ${schedules.length + 1}`);
|
||
setCreateDialogOpen(true);
|
||
};
|
||
|
||
const addSchedule = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||
event.preventDefault();
|
||
const scheduleName = newScheduleName.trim();
|
||
if (busy || !scheduleName) return;
|
||
setBusy("create-schedule");
|
||
try {
|
||
const created = await api.createSchedule({
|
||
schedule_name: scheduleName,
|
||
description: "在画布中拖入稳定版本并配置执行顺序",
|
||
trigger_type: "manual",
|
||
timezone: "Asia/Shanghai",
|
||
enabled: false,
|
||
});
|
||
setSchedules((current) => [created, ...current]);
|
||
setSchedule(created);
|
||
useSchedulesStore.getState().clearAllPositionDrafts();
|
||
setSelectedNodeId(null);
|
||
setCreateDialogOpen(false);
|
||
setNewScheduleName("");
|
||
onNotify({ tone: "success", message: "调度方案已创建" });
|
||
} catch (error) {
|
||
await handleError(error, "创建调度失败");
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const removeSchedule = async (target?: Schedule): Promise<void> => {
|
||
const selectedSchedule = target ?? schedule;
|
||
if (!selectedSchedule || busy) return;
|
||
setContextMenu(null);
|
||
if (!window.confirm(`确定删除调度"${selectedSchedule.schedule_name}"吗?`)) return;
|
||
setBusy("delete-schedule");
|
||
try {
|
||
await api.deleteSchedule(
|
||
selectedSchedule.schedule_id,
|
||
selectedSchedule.workflow_version,
|
||
);
|
||
const remaining = schedules.filter(
|
||
(item) => item.schedule_id !== selectedSchedule.schedule_id,
|
||
);
|
||
setSchedules(remaining);
|
||
if (schedule?.schedule_id === selectedSchedule.schedule_id) {
|
||
setSchedule(null);
|
||
useSchedulesStore.getState().clearAllPositionDrafts();
|
||
setSelectedNodeId(null);
|
||
setSelectedEdgeId(null);
|
||
if (remaining[0]) {
|
||
setSchedule(await api.getSchedule(remaining[0].schedule_id));
|
||
}
|
||
}
|
||
onNotify({ tone: "success", message: "调度方案已删除" });
|
||
} catch (error) {
|
||
await handleError(error, "删除调度失败");
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const renameSchedule = async (target: Schedule): Promise<void> => {
|
||
if (busy) return;
|
||
setContextMenu(null);
|
||
const scheduleName = window.prompt("请输入新的调度方案名称", target.schedule_name)?.trim();
|
||
if (!scheduleName || scheduleName === target.schedule_name) return;
|
||
setBusy("rename-schedule");
|
||
try {
|
||
const updated = await api.updateSchedule(target.schedule_id, {
|
||
workflow_version: target.workflow_version,
|
||
schedule_name: scheduleName,
|
||
});
|
||
setSchedules((current) => current.map((item) => (
|
||
item.schedule_id === updated.schedule_id
|
||
? { ...updated, nodes: [], edges: [] }
|
||
: item
|
||
)));
|
||
if (schedule?.schedule_id === updated.schedule_id) setSchedule(updated);
|
||
onNotify({ tone: "success", message: "调度方案已改名" });
|
||
} catch (error) {
|
||
await handleError(error, "调度方案改名失败");
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const removeArtifact = async (
|
||
artifact: ScheduleArtifact,
|
||
): Promise<void> => {
|
||
if (busy) return;
|
||
setContextMenu(null);
|
||
if (
|
||
!window.confirm(
|
||
`确定将"${artifact.script_name} ${artifact.version_label}"移出调度列表吗?\n`
|
||
+ "稳定版本本身和历史运行记录不会被删除。",
|
||
)
|
||
) return;
|
||
setBusy("delete-artifact");
|
||
try {
|
||
await api.hideScheduleArtifact(artifact.versions_id);
|
||
setArtifacts((current) => current.filter(
|
||
(item) => item.versions_id !== artifact.versions_id,
|
||
));
|
||
onNotify({
|
||
tone: "success",
|
||
message: "已移出调度列表,稳定版本和历史记录保持不变",
|
||
});
|
||
} catch (error) {
|
||
await handleError(error, "移出调度列表失败");
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const saveSchedule = async (): Promise<void> => {
|
||
if (!schedule || busy) return;
|
||
const maxConcurrency = Number(scheduleForm.maxConcurrency);
|
||
if (!scheduleForm.scheduleName.trim()) {
|
||
onNotify({ tone: "error", message: "调度名称不能为空" });
|
||
return;
|
||
}
|
||
if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1) {
|
||
onNotify({ tone: "error", message: "最大并发数必须是正整数" });
|
||
return;
|
||
}
|
||
setBusy("save-schedule");
|
||
let updated = useSchedulesStore.getState().schedule ?? schedule;
|
||
try {
|
||
const drafts = useSchedulesStore.getState().getPositionDrafts();
|
||
for (const [nodeId, position] of drafts.entries()) {
|
||
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,
|
||
});
|
||
useSchedulesStore.getState().clearAllPositionDrafts();
|
||
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<void> => {
|
||
if (scheduleForm.triggerType !== "cron") return;
|
||
if (busy) return;
|
||
setBusy("cron-preview");
|
||
try {
|
||
const result = await api.previewCron({
|
||
cron_expression: scheduleForm.cronExpression.trim(),
|
||
timezone: scheduleForm.timezone.trim(),
|
||
count: 5,
|
||
});
|
||
setCronResult(result);
|
||
onNotify({ tone: "success", message: "Cron 表达式校验通过" });
|
||
} catch (error) {
|
||
setCronResult(null);
|
||
await handleError(error, "Cron 预览失败");
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const runNow = async (): Promise<void> => {
|
||
if (!schedule || busy) return;
|
||
if (positionDraftCount > 0) {
|
||
onNotify({
|
||
tone: "info",
|
||
message: "还有节点位置未保存,请先点击'保存配置'再运行",
|
||
});
|
||
return;
|
||
}
|
||
if (!schedule.dag_validation.valid || schedule.nodes.length === 0) {
|
||
onNotify({
|
||
tone: "error",
|
||
message: "当前调度必须包含有效的非空 DAG 才能运行",
|
||
});
|
||
return;
|
||
}
|
||
setBusy("run-now");
|
||
try {
|
||
const created = await api.runScheduleNow(schedule.schedule_id);
|
||
setRuns((current) => [
|
||
created,
|
||
...current.filter((item) => item.run_id !== created.run_id),
|
||
].slice(0, 20));
|
||
setSchedule((current) => (
|
||
current ? { ...current, last_run_at: created.queued_at } : current
|
||
));
|
||
setSchedules((current) => current.map((item) => (
|
||
item.schedule_id === schedule.schedule_id
|
||
? { ...item, last_run_at: created.queued_at }
|
||
: item
|
||
)));
|
||
onNotify({
|
||
tone: "success",
|
||
message: `运行 ${created.run_id.slice(-8)} 已进入队列`,
|
||
});
|
||
} catch (error) {
|
||
await handleError(error, "立即运行失败");
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const addArtifactAt = async (
|
||
artifact: ScheduleArtifact,
|
||
positionX: number,
|
||
positionY: number,
|
||
): Promise<void> => {
|
||
if (!schedule) {
|
||
onNotify({ tone: "info", message: "请先新建或选择一个调度方案" });
|
||
return;
|
||
}
|
||
const nodeKey = artifactNodeKey(artifact, schedule);
|
||
const updated = await withMutation(
|
||
"add-node",
|
||
() => api.createScheduleNode(schedule.schedule_id, {
|
||
workflow_version: schedule.workflow_version,
|
||
node_key: nodeKey,
|
||
node_name: artifact.script_name,
|
||
versions_id: artifact.versions_id,
|
||
timeout_seconds: 600,
|
||
retry_count: 0,
|
||
retry_interval_sec: 5,
|
||
position_x: Math.max(20, Math.round(positionX)),
|
||
position_y: Math.max(20, Math.round(positionY)),
|
||
arguments_json: {},
|
||
env_refs_json: {},
|
||
}),
|
||
`${artifact.script_name} 已加入画布`,
|
||
);
|
||
if (updated) {
|
||
const created = updated.nodes.find((item) => item.node_key === nodeKey);
|
||
setSelectedNodeId(created?.node_id ?? null);
|
||
}
|
||
};
|
||
|
||
const onCanvasDrop = (event: DragEvent<HTMLDivElement>): void => {
|
||
event.preventDefault();
|
||
const versionsId = event.dataTransfer.getData(ARTIFACT_MIME)
|
||
|| event.dataTransfer.getData("text/plain");
|
||
const artifact = artifacts.find((item) => item.versions_id === versionsId);
|
||
if (!artifact || !canvasRef.current) return;
|
||
const rect = canvasRef.current.getBoundingClientRect();
|
||
const positionX = event.clientX - rect.left
|
||
+ canvasRef.current.scrollLeft - NODE_WIDTH / 2;
|
||
const positionY = event.clientY - rect.top
|
||
+ canvasRef.current.scrollTop - NODE_HEIGHT / 2;
|
||
void addArtifactAt(artifact, positionX, positionY);
|
||
};
|
||
|
||
const startNodeDrag = (
|
||
event: ReactPointerEvent<HTMLDivElement>,
|
||
node: ScheduleNode,
|
||
): void => {
|
||
if (event.button !== 0 || busy || linkSourceId) return;
|
||
const target = event.target as HTMLElement;
|
||
if (target.closest("button")) return;
|
||
event.currentTarget.setPointerCapture(event.pointerId);
|
||
dragRef.current = {
|
||
nodeId: node.node_id,
|
||
pointerId: event.pointerId,
|
||
startClientX: event.clientX,
|
||
startClientY: event.clientY,
|
||
originX: node.position_x,
|
||
originY: node.position_y,
|
||
moved: false,
|
||
};
|
||
setSelectedNodeId(node.node_id);
|
||
setSelectedEdgeId(null);
|
||
};
|
||
|
||
const moveNode = (
|
||
event: ReactPointerEvent<HTMLDivElement>,
|
||
): void => {
|
||
const drag = dragRef.current;
|
||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||
const deltaX = event.clientX - drag.startClientX;
|
||
const deltaY = event.clientY - drag.startClientY;
|
||
if (Math.abs(deltaX) + Math.abs(deltaY) > 3) drag.moved = true;
|
||
setSchedule((current) => {
|
||
if (!current) return current;
|
||
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<HTMLDivElement>,
|
||
): 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),
|
||
};
|
||
useSchedulesStore.getState().setPositionDraft(node.node_id, position);
|
||
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<void> => {
|
||
if (!schedule || !linkSourceId || busy) return;
|
||
if (linkSourceId === targetNodeId) {
|
||
setLinkSourceId(null);
|
||
onNotify({ tone: "info", message: "已取消连线" });
|
||
return;
|
||
}
|
||
const sourceId = linkSourceId;
|
||
setLinkSourceId(null);
|
||
await withMutation(
|
||
"create-edge",
|
||
() => api.createScheduleEdge(schedule.schedule_id, {
|
||
workflow_version: schedule.workflow_version,
|
||
source_node_id: sourceId,
|
||
target_node_id: targetNodeId,
|
||
}),
|
||
"节点连线已创建",
|
||
);
|
||
};
|
||
|
||
const saveNode = async (): Promise<void> => {
|
||
if (!schedule || !selectedNode) return;
|
||
try {
|
||
const timeoutSeconds = Number(nodeForm.timeoutSeconds);
|
||
const retryCount = Number(nodeForm.retryCount);
|
||
const retryIntervalSec = Number(nodeForm.retryIntervalSec);
|
||
if (!nodeForm.nodeName.trim()) throw new Error("节点名称不能为空");
|
||
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1) {
|
||
throw new Error("超时时间必须是正整数");
|
||
}
|
||
if (!Number.isInteger(retryCount) || retryCount < 0) {
|
||
throw new Error("重试次数必须是非负整数");
|
||
}
|
||
if (!Number.isInteger(retryIntervalSec) || retryIntervalSec < 0) {
|
||
throw new Error("重试间隔必须是非负整数");
|
||
}
|
||
const argumentsJson = parseObject(nodeForm.argumentsJson, "运行参数");
|
||
const rawEnv = parseObject(nodeForm.envRefsJson, "环境引用");
|
||
const envRefsJson = Object.fromEntries(
|
||
Object.entries(rawEnv).map(([key, value]) => {
|
||
if (typeof value !== "string") {
|
||
throw new Error("环境引用的值必须是字符串");
|
||
}
|
||
return [key, value];
|
||
}),
|
||
);
|
||
await withMutation(
|
||
"save-node",
|
||
() => api.updateScheduleNode(schedule.schedule_id, selectedNode.node_id, {
|
||
workflow_version: schedule.workflow_version,
|
||
node_name: nodeForm.nodeName.trim(),
|
||
timeout_seconds: timeoutSeconds,
|
||
retry_count: retryCount,
|
||
retry_interval_sec: retryIntervalSec,
|
||
arguments_json: argumentsJson,
|
||
env_refs_json: envRefsJson,
|
||
}),
|
||
"节点配置已保存",
|
||
);
|
||
} catch (error) {
|
||
await handleError(error, "节点配置保存失败");
|
||
}
|
||
};
|
||
|
||
const removeNode = async (target?: ScheduleNode): Promise<void> => {
|
||
const node = target ?? selectedNode;
|
||
if (!schedule || !node || busy) return;
|
||
setContextMenu(null);
|
||
if (!window.confirm(`确定删除节点"${node.node_name}"吗?`)) return;
|
||
const updated = await withMutation(
|
||
"delete-node",
|
||
() => api.deleteScheduleNode(
|
||
schedule.schedule_id,
|
||
node.node_id,
|
||
schedule.workflow_version,
|
||
),
|
||
"节点已删除",
|
||
);
|
||
if (updated && selectedNodeId === node.node_id) setSelectedNodeId(null);
|
||
};
|
||
|
||
const removeEdge = async (target?: ScheduleEdge): Promise<void> => {
|
||
const edge = target ?? selectedEdge;
|
||
if (!schedule || !edge || busy) return;
|
||
setContextMenu(null);
|
||
const updated = await withMutation(
|
||
"delete-edge",
|
||
() => api.deleteScheduleEdge(
|
||
schedule.schedule_id,
|
||
edge.edge_id,
|
||
schedule.workflow_version,
|
||
),
|
||
"连线已删除",
|
||
);
|
||
if (updated && selectedEdgeId === edge.edge_id) setSelectedEdgeId(null);
|
||
};
|
||
|
||
const checkDag = async (): Promise<void> => {
|
||
if (!schedule || busy) return;
|
||
setBusy("validate");
|
||
try {
|
||
const result = await api.validateSchedule(schedule.schedule_id);
|
||
setSchedule((current) => current
|
||
? { ...current, dag_validation: result }
|
||
: current);
|
||
onNotify({
|
||
tone: result.valid ? "success" : "error",
|
||
message: result.valid
|
||
? `DAG 校验通过,共 ${result.node_count} 个节点`
|
||
: result.errors.map((item) => item.message).join(";"),
|
||
});
|
||
} catch (error) {
|
||
await handleError(error, "DAG 校验失败");
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
const filteredSchedules = useMemo(() => {
|
||
const keyword = scheduleKeyword.trim().toLowerCase();
|
||
return keyword
|
||
? schedules.filter((item) => item.schedule_name.toLowerCase().includes(keyword))
|
||
: schedules;
|
||
}, [schedules, scheduleKeyword]);
|
||
|
||
const filteredArtifacts = useMemo(() => {
|
||
const keyword = artifactKeyword.trim().toLowerCase();
|
||
return keyword
|
||
? artifacts.filter((item) => (
|
||
item.script_name.toLowerCase().includes(keyword)
|
||
|| item.version_label.toLowerCase().includes(keyword)
|
||
))
|
||
: artifacts;
|
||
}, [artifacts, artifactKeyword]);
|
||
|
||
return (
|
||
<section className="schedule-page">
|
||
<header className="schedule-toolbar">
|
||
<div>
|
||
<strong>图形化调度</strong>
|
||
<span>
|
||
{schedule
|
||
? `版本 ${schedule.workflow_version} · ${schedule.dag_validation.valid ? "DAG 有效" : "待校验"}${
|
||
positionDraftCount > 0
|
||
? ` · ${positionDraftCount} 个节点位置待保存`
|
||
: ""
|
||
}`
|
||
: "创建调度后开始编排"}
|
||
</span>
|
||
</div>
|
||
<div className="schedule-toolbar__actions">
|
||
<button type="button" onClick={openCreateScheduleDialog}>
|
||
<Icon name="plus" size={15} />新建
|
||
</button>
|
||
<button
|
||
className="schedule-action--primary"
|
||
type="button"
|
||
disabled={!schedule || Boolean(busy)}
|
||
onClick={() => void saveSchedule()}
|
||
>
|
||
<Icon name="check" size={15} />保存配置
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!schedule || Boolean(busy)}
|
||
onClick={() => void checkDag()}
|
||
>
|
||
校验 DAG
|
||
</button>
|
||
<button
|
||
className="schedule-action--run"
|
||
type="button"
|
||
disabled={
|
||
!schedule
|
||
|| Boolean(busy)
|
||
|| !schedule.dag_validation.valid
|
||
|| schedule.nodes.length === 0
|
||
}
|
||
title={
|
||
positionDraftCount > 0
|
||
? "请先保存节点位置"
|
||
: "立即执行当前已保存的稳定版本 DAG"
|
||
}
|
||
onClick={() => void runNow()}
|
||
>
|
||
<Icon name="play" size={14} />立即运行
|
||
</button>
|
||
<button
|
||
className="schedule-action--danger"
|
||
type="button"
|
||
disabled={!schedule || Boolean(busy)}
|
||
onClick={() => void removeSchedule(schedule ?? undefined)}
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="schedule-workbench">
|
||
<aside className="schedule-left">
|
||
<ScheduleList
|
||
schedules={filteredSchedules}
|
||
selectedScheduleId={schedule?.schedule_id ?? null}
|
||
keyword={scheduleKeyword}
|
||
loading={loading}
|
||
onSelect={chooseSchedule}
|
||
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 addArtifactAt(artifact, x, y)}
|
||
onDoubleClick={(artifact) => void 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) => {
|
||
if (event.target === event.currentTarget) {
|
||
setSelectedNodeId(null);
|
||
setSelectedEdgeId(null);
|
||
}
|
||
}}
|
||
>
|
||
<div
|
||
className="schedule-canvas__surface"
|
||
style={{ width: CANVAS_WIDTH, height: CANVAS_HEIGHT }}
|
||
>
|
||
{schedule && (
|
||
<svg
|
||
className="schedule-edges"
|
||
viewBox={`0 0 ${CANVAS_WIDTH} ${CANVAS_HEIGHT}`}
|
||
aria-label="调度连线"
|
||
>
|
||
<defs>
|
||
<marker
|
||
id="schedule-arrow"
|
||
markerWidth="8"
|
||
markerHeight="8"
|
||
refX="7"
|
||
refY="4"
|
||
orient="auto"
|
||
>
|
||
<path d="M0,0 L8,4 L0,8 Z" fill="#4e92db" />
|
||
</marker>
|
||
</defs>
|
||
{schedule.edges.map((edge) => {
|
||
const source = schedule.nodes.find(
|
||
(node) => node.node_id === edge.source_node_id,
|
||
);
|
||
const target = schedule.nodes.find(
|
||
(node) => node.node_id === edge.target_node_id,
|
||
);
|
||
if (!source || !target) return null;
|
||
const path = edgePath(source, target);
|
||
return (
|
||
<g
|
||
className={selectedEdgeId === edge.edge_id ? "is-selected" : ""}
|
||
key={edge.edge_id}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
setSelectedEdgeId(edge.edge_id);
|
||
setSelectedNodeId(null);
|
||
}}
|
||
onContextMenu={(event) => openContextMenu(event, {
|
||
kind: "edge",
|
||
edge,
|
||
})}
|
||
>
|
||
<path className="schedule-edge-hit" d={path} />
|
||
<path
|
||
className="schedule-edge-line"
|
||
d={path}
|
||
markerEnd="url(#schedule-arrow)"
|
||
/>
|
||
</g>
|
||
);
|
||
})}
|
||
</svg>
|
||
)}
|
||
|
||
{schedule?.nodes.map((node) => (
|
||
<div
|
||
className={`schedule-node${
|
||
selectedNodeId === node.node_id ? " is-selected" : ""
|
||
}`}
|
||
key={node.node_id}
|
||
style={{
|
||
left: node.position_x,
|
||
top: node.position_y,
|
||
width: NODE_WIDTH,
|
||
height: NODE_HEIGHT,
|
||
}}
|
||
onPointerDown={(event) => startNodeDrag(event, node)}
|
||
onPointerMove={moveNode}
|
||
onPointerUp={finishNodeDrag}
|
||
onPointerCancel={finishNodeDrag}
|
||
onContextMenu={(event) => openContextMenu(event, {
|
||
kind: "node",
|
||
node,
|
||
})}
|
||
>
|
||
<button
|
||
className="schedule-node__port schedule-node__port--in"
|
||
type="button"
|
||
aria-label={`连接到${node.node_name}`}
|
||
title={linkSourceId ? "点击完成连线" : "输入端口"}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
void connectTo(node.node_id);
|
||
}}
|
||
/>
|
||
<div className="schedule-node__heading">
|
||
<span className={`schedule-node__type schedule-node__type--${node.version.script_type}`}>
|
||
<Icon
|
||
name={node.version.script_type === "notebook" ? "notebook" : "python"}
|
||
size={14}
|
||
/>
|
||
</span>
|
||
<strong>{node.node_name}</strong>
|
||
<button
|
||
type="button"
|
||
aria-label="删除节点"
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
setSelectedNodeId(node.node_id);
|
||
setSelectedEdgeId(null);
|
||
}}
|
||
>
|
||
<Icon name="settings" size={14} />
|
||
</button>
|
||
</div>
|
||
<p>{node.version.script_name}</p>
|
||
<footer>
|
||
<span>{node.node_key}</span>
|
||
<b>{node.version.version_label}</b>
|
||
</footer>
|
||
<button
|
||
className={`schedule-node__port schedule-node__port--out${
|
||
linkSourceId === node.node_id ? " is-active" : ""
|
||
}`}
|
||
type="button"
|
||
aria-label={`从${node.node_name}开始连线`}
|
||
title="输出端口"
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
setLinkSourceId((current) => (
|
||
current === node.node_id ? null : node.node_id
|
||
));
|
||
setSelectedNodeId(node.node_id);
|
||
setSelectedEdgeId(null);
|
||
}}
|
||
/>
|
||
</div>
|
||
))}
|
||
|
||
{!schedule ? (
|
||
<div className="schedule-canvas-empty">
|
||
<Icon name="schedule" size={34} />
|
||
<strong>还没有选中调度方案</strong>
|
||
<p>点击"新建"创建一个调度,然后拖入稳定版本脚本。</p>
|
||
<button type="button" onClick={openCreateScheduleDialog}>
|
||
<Icon name="plus" size={15} />新建调度
|
||
</button>
|
||
</div>
|
||
) : schedule.nodes.length === 0 ? (
|
||
<div className="schedule-canvas-empty">
|
||
<Icon name="release" size={34} />
|
||
<strong>从稳定版本开始编排</strong>
|
||
<p>把左侧脚本卡片拖到这里,或双击卡片快速加入。</p>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<footer className="schedule-canvas-status">
|
||
<span className={schedule?.dag_validation.valid ? "is-valid" : ""}>
|
||
{schedule?.dag_validation.valid ? "✓ DAG 有效" : "○ DAG 待校验"}
|
||
</span>
|
||
<span>{schedule?.nodes.length ?? 0} 个节点</span>
|
||
<span>{schedule?.edges.length ?? 0} 条连线</span>
|
||
{selectedEdge && (
|
||
<button type="button" onClick={() => void removeEdge(selectedEdge)}>
|
||
删除选中连线
|
||
</button>
|
||
)}
|
||
</footer>
|
||
</main>
|
||
|
||
<aside className="schedule-right">
|
||
<div className="schedule-inspector-scroll">
|
||
{selectedNode ? (
|
||
<NodeInspector
|
||
node={selectedNode}
|
||
form={nodeForm}
|
||
busy={Boolean(busy)}
|
||
onChange={setNodeForm}
|
||
onSave={() => void saveNode()}
|
||
onDelete={() => void removeNode(selectedNode)}
|
||
/>
|
||
) : (
|
||
<ScheduleInspector
|
||
schedule={schedule}
|
||
form={scheduleForm}
|
||
cronResult={cronResult}
|
||
busy={Boolean(busy)}
|
||
onChange={setScheduleForm}
|
||
onPreview={() => void runCronPreview()}
|
||
onSave={() => void saveSchedule()}
|
||
/>
|
||
)}
|
||
</div>
|
||
<RunHistory
|
||
runs={runs}
|
||
nodes={schedule?.nodes ?? []}
|
||
loading={runsLoading}
|
||
disabled={!schedule || Boolean(busy)}
|
||
onRefresh={() => {
|
||
if (schedule) void refreshRuns(schedule.schedule_id, true);
|
||
}}
|
||
/>
|
||
</aside>
|
||
</div>
|
||
|
||
{contextMenu && (
|
||
<div
|
||
className="schedule-context-menu"
|
||
role="menu"
|
||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
>
|
||
{contextMenu.kind === "schedule-list" && (
|
||
<button
|
||
className="schedule-context-menu__action"
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={openCreateScheduleDialog}
|
||
>
|
||
<Icon name="plus" size={14} />
|
||
新建调度方案
|
||
</button>
|
||
)}
|
||
{contextMenu.kind === "schedule" && (
|
||
<>
|
||
<button
|
||
className="schedule-context-menu__action"
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={openCreateScheduleDialog}
|
||
>
|
||
<Icon name="plus" size={14} />
|
||
新建调度方案
|
||
</button>
|
||
<button
|
||
className="schedule-context-menu__action"
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={() => void renameSchedule(contextMenu.schedule)}
|
||
>
|
||
<Icon name="settings" size={14} />
|
||
改名
|
||
</button>
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={() => void removeSchedule(contextMenu.schedule)}
|
||
>
|
||
<Icon name="close" size={14} />
|
||
删除调度方案
|
||
</button>
|
||
</>
|
||
)}
|
||
{contextMenu.kind === "artifact" && (
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={() => void removeArtifact(contextMenu.artifact)}
|
||
>
|
||
<Icon name="close" size={14} />
|
||
移出调度列表
|
||
</button>
|
||
)}
|
||
{contextMenu.kind === "node" && (
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={() => void removeNode(contextMenu.node)}
|
||
>
|
||
<Icon name="close" size={14} />
|
||
删除画布节点
|
||
</button>
|
||
)}
|
||
{contextMenu.kind === "edge" && (
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={() => void removeEdge(contextMenu.edge)}
|
||
>
|
||
<Icon name="close" size={14} />
|
||
删除画布连线
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{createDialogOpen && (
|
||
<div className="modal-backdrop" role="presentation">
|
||
<section
|
||
className="modal modal--compact"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="create-schedule-title"
|
||
>
|
||
<div className="modal__header">
|
||
<div>
|
||
<span className="modal__eyebrow">SCHEDULE</span>
|
||
<h2 id="create-schedule-title">新建调度方案</h2>
|
||
</div>
|
||
<button
|
||
className="icon-button"
|
||
type="button"
|
||
aria-label="关闭"
|
||
disabled={Boolean(busy)}
|
||
onClick={() => setCreateDialogOpen(false)}
|
||
>
|
||
<Icon name="close" />
|
||
</button>
|
||
</div>
|
||
<form onSubmit={(event) => void addSchedule(event)}>
|
||
<label className="form-field">
|
||
<span>调度方案名称</span>
|
||
<input
|
||
autoFocus
|
||
maxLength={255}
|
||
placeholder="请输入调度方案名称"
|
||
value={newScheduleName}
|
||
onChange={(event) => setNewScheduleName(event.target.value)}
|
||
onFocus={(event) => event.currentTarget.select()}
|
||
/>
|
||
</label>
|
||
<div className="modal__footer">
|
||
<button
|
||
className="secondary-button"
|
||
type="button"
|
||
disabled={Boolean(busy)}
|
||
onClick={() => setCreateDialogOpen(false)}
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
className="primary-button"
|
||
type="submit"
|
||
disabled={Boolean(busy) || !newScheduleName.trim()}
|
||
>
|
||
{busy === "create-schedule"
|
||
? <span className="button-spinner" />
|
||
: <Icon name="plus" size={15} />}
|
||
{busy === "create-schedule" ? "正在创建…" : "创建调度"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</section>
|
||
</div>
|
||
)}
|
||
|
||
{busy && (
|
||
<div className="schedule-busy" aria-live="polite">
|
||
<span />
|
||
{busy === "run-now" ? "正在创建运行…" : "正在同步调度配置…"}
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
function RunHistory({
|
||
runs,
|
||
nodes,
|
||
loading,
|
||
disabled,
|
||
onRefresh,
|
||
}: {
|
||
runs: ScheduleRunSummary[];
|
||
nodes: ScheduleNode[];
|
||
loading: boolean;
|
||
disabled: boolean;
|
||
onRefresh: () => void;
|
||
}) {
|
||
const api = useApi();
|
||
const [expandedRunId, setExpandedRunId] = useState<string | null>(null);
|
||
const [runDetails, setRunDetails] = useState<Record<string, ScheduleRunDetail>>({});
|
||
const [detailLoadingId, setDetailLoadingId] = useState<string | null>(null);
|
||
const [detailErrors, setDetailErrors] = useState<Record<string, string>>({});
|
||
const [openLogNodeRunIds, setOpenLogNodeRunIds] = useState<Set<string>>(
|
||
() => new Set(),
|
||
);
|
||
const [logStates, setLogStates] = useState<Record<string, {
|
||
loading: boolean;
|
||
content: string | null;
|
||
fileName: string | null;
|
||
error: string | null;
|
||
}>>({});
|
||
const [artifactBusyKey, setArtifactBusyKey] = useState<string | null>(null);
|
||
const [artifactErrors, setArtifactErrors] = useState<Record<string, string>>({});
|
||
|
||
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<void> => {
|
||
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<void> => {
|
||
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<void> => {
|
||
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 (
|
||
<section className="schedule-run-history">
|
||
<header>
|
||
<div>
|
||
<strong>运行记录</strong>
|
||
<span>{runs.length}</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
aria-label="刷新运行记录"
|
||
disabled={disabled || loading}
|
||
onClick={onRefresh}
|
||
>
|
||
<Icon name="refresh" size={13} />
|
||
</button>
|
||
</header>
|
||
<div className="schedule-run-list">
|
||
{loading && runs.length === 0 ? (
|
||
<p>正在加载运行记录…</p>
|
||
) : runs.length === 0 ? (
|
||
<p>点击右上角“立即运行”后,这里会显示状态和耗时。</p>
|
||
) : (
|
||
runs.map((run) => {
|
||
const expanded = expandedRunId === run.run_id;
|
||
const detail = runDetails[run.run_id];
|
||
return (
|
||
<article
|
||
className={`schedule-run-card${expanded ? " is-expanded" : ""}`}
|
||
key={run.run_id}
|
||
>
|
||
<div
|
||
className="schedule-run-summary"
|
||
>
|
||
<span className={`run-status-dot is-${run.run_status}`} />
|
||
<span className="schedule-run-summary-copy">
|
||
<strong>{RUN_STATUS_LABELS[run.run_status]}</strong>
|
||
<small>
|
||
{formatTime(run.queued_at)} · {formatDuration(run.duration_ms)}
|
||
</small>
|
||
{run.error_message && <span>{run.error_message}</span>}
|
||
</span>
|
||
<code title={run.run_id}>{run.run_id.slice(-8)}</code>
|
||
<button
|
||
className="schedule-run-result-button"
|
||
type="button"
|
||
aria-haspopup="dialog"
|
||
onClick={() => toggleRun(run.run_id)}
|
||
>
|
||
查看结果
|
||
</button>
|
||
</div>
|
||
|
||
{expanded && (
|
||
<>
|
||
<button
|
||
className="schedule-run-result-backdrop"
|
||
type="button"
|
||
aria-label="关闭运行结果"
|
||
onClick={() => toggleRun(run.run_id)}
|
||
/>
|
||
<section
|
||
className="schedule-run-result-modal"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label="调度运行结果"
|
||
>
|
||
<header className="schedule-run-result-modal__header">
|
||
<div>
|
||
<span>调度运行结果</span>
|
||
<strong>{RUN_STATUS_LABELS[run.run_status]}</strong>
|
||
<code>{run.run_id}</code>
|
||
</div>
|
||
<button
|
||
className="icon-button"
|
||
type="button"
|
||
aria-label="关闭运行结果"
|
||
onClick={() => toggleRun(run.run_id)}
|
||
>
|
||
<Icon name="close" size={16} />
|
||
</button>
|
||
</header>
|
||
<div className="schedule-run-detail">
|
||
{detailLoadingId === run.run_id && !detail ? (
|
||
<p className="run-detail-message">正在加载运行详情…</p>
|
||
) : detailErrors[run.run_id] ? (
|
||
<p className="run-detail-message is-error">
|
||
{detailErrors[run.run_id]}
|
||
</p>
|
||
) : detail ? (
|
||
<>
|
||
<dl className="schedule-run-meta">
|
||
<div><dt>触发方式</dt><dd>{TRIGGER_TYPE_LABELS[detail.trigger_type]}</dd></div>
|
||
<div><dt>工作流版本</dt><dd>v{detail.workflow_version}</dd></div>
|
||
<div><dt>开始时间</dt><dd>{formatTime(detail.started_at)}</dd></div>
|
||
<div><dt>完成时间</dt><dd>{formatTime(detail.finished_at)}</dd></div>
|
||
</dl>
|
||
{(detail.error_code || detail.error_message) && (
|
||
<div className="schedule-run-error">
|
||
{detail.error_code && <code>{detail.error_code}</code>}
|
||
{detail.error_message && <p>{detail.error_message}</p>}
|
||
</div>
|
||
)}
|
||
<div className="schedule-node-run-list">
|
||
{detail.node_runs.length === 0 ? (
|
||
<p className="run-detail-message">节点尚未开始执行</p>
|
||
) : 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 (
|
||
<article className="schedule-node-run" key={nodeRun.node_run_id}>
|
||
<header>
|
||
<span className={`run-status-dot is-${nodeRun.node_status}`} />
|
||
<strong>{node?.node_name ?? nodeRun.node_id.slice(-8)}</strong>
|
||
<span>{NODE_STATUS_LABELS[nodeRun.node_status]}</span>
|
||
</header>
|
||
<div className="schedule-node-run-meta">
|
||
<span>第 {nodeRun.attempt_no} 次</span>
|
||
<span>{formatDuration(nodeRun.duration_ms)}</span>
|
||
<span>退出码 {nodeRun.exit_code ?? "—"}</span>
|
||
</div>
|
||
{nodeRun.message && <p>{nodeRun.message}</p>}
|
||
<div className="schedule-node-run-actions">
|
||
<button
|
||
type="button"
|
||
disabled={!nodeRun.logs_object_id || logState?.loading}
|
||
onClick={() => void toggleLog(run.run_id, nodeRun.node_run_id)}
|
||
>
|
||
{logState?.loading ? "读取中…" : logOpen ? "收起日志" : "查看日志"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={
|
||
!nodeRun.logs_object_id
|
||
|| artifactBusyKey === `${nodeRun.node_run_id}:log-download`
|
||
}
|
||
onClick={() => void downloadLog(run.run_id, nodeRun.node_run_id)}
|
||
>
|
||
{artifactBusyKey === `${nodeRun.node_run_id}:log-download`
|
||
? "准备中…"
|
||
: "下载日志"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={
|
||
!nodeRun.result_object_id
|
||
|| artifactBusyKey === `${nodeRun.node_run_id}:result`
|
||
}
|
||
onClick={() => void downloadResult(run.run_id, nodeRun.node_run_id)}
|
||
>
|
||
{artifactBusyKey === `${nodeRun.node_run_id}:result`
|
||
? "准备中…"
|
||
: "下载结果"}
|
||
</button>
|
||
</div>
|
||
{artifactErrors[nodeRun.node_run_id] && (
|
||
<p className="schedule-artifact-error">
|
||
{artifactErrors[nodeRun.node_run_id]}
|
||
</p>
|
||
)}
|
||
{logOpen && (
|
||
<div className="schedule-node-log">
|
||
{logState?.fileName && (
|
||
<header>
|
||
<span>{logState.fileName}</span>
|
||
<small>
|
||
{formatSize(new TextEncoder().encode(logState.content ?? "").length)}
|
||
</small>
|
||
</header>
|
||
)}
|
||
{logState?.error ? (
|
||
<p>{logState.error}</p>
|
||
) : (
|
||
<pre>{logState?.content ?? "正在读取日志…"}</pre>
|
||
)}
|
||
</div>
|
||
)}
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</section>
|
||
</>
|
||
)}
|
||
</article>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
|
||
function ScheduleInspector({
|
||
schedule,
|
||
form,
|
||
cronResult,
|
||
busy,
|
||
onChange,
|
||
onPreview,
|
||
onSave,
|
||
}: {
|
||
schedule: Schedule | null;
|
||
form: ScheduleForm;
|
||
cronResult: CronPreview | null;
|
||
busy: boolean;
|
||
onChange: (form: ScheduleForm) => void;
|
||
onPreview: () => void;
|
||
onSave: () => void;
|
||
}) {
|
||
if (!schedule) {
|
||
return (
|
||
<div className="schedule-inspector-empty">
|
||
<Icon name="settings" size={28} />
|
||
<strong>调度属性</strong>
|
||
<p>选中调度方案后可配置触发方式、Cron 和执行策略。</p>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div className="schedule-inspector">
|
||
<section>
|
||
<h3>基本信息</h3>
|
||
<label>
|
||
<span>调度名称</span>
|
||
<input
|
||
value={form.scheduleName}
|
||
onChange={(event) => onChange({
|
||
...form,
|
||
scheduleName: event.target.value,
|
||
})}
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span>说明</span>
|
||
<textarea
|
||
rows={2}
|
||
value={form.description}
|
||
onChange={(event) => onChange({
|
||
...form,
|
||
description: event.target.value,
|
||
})}
|
||
/>
|
||
</label>
|
||
<label className="schedule-switch-row">
|
||
<span>
|
||
<b>启用调度</b>
|
||
<small>DAG 有效后才能启用</small>
|
||
</span>
|
||
<input
|
||
type="checkbox"
|
||
checked={form.enabled}
|
||
onChange={(event) => onChange({
|
||
...form,
|
||
enabled: event.target.checked,
|
||
})}
|
||
/>
|
||
</label>
|
||
</section>
|
||
|
||
<section>
|
||
<h3>触发器</h3>
|
||
<label>
|
||
<span>触发方式</span>
|
||
<select
|
||
value={form.triggerType}
|
||
onChange={(event) => onChange({
|
||
...form,
|
||
triggerType: event.target.value as ScheduleForm["triggerType"],
|
||
enabled: event.target.value === "cron" ? form.enabled : false,
|
||
})}
|
||
>
|
||
<option value="manual">手动触发</option>
|
||
<option value="cron">Cron 定时</option>
|
||
<option value="api">API 触发</option>
|
||
</select>
|
||
</label>
|
||
{form.triggerType === "cron" && (
|
||
<>
|
||
<label>
|
||
<span>Cron(分 时 日 月 周)</span>
|
||
<input
|
||
value={form.cronExpression}
|
||
onChange={(event) => onChange({
|
||
...form,
|
||
cronExpression: event.target.value,
|
||
})}
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span>时区</span>
|
||
<input
|
||
value={form.timezone}
|
||
onChange={(event) => onChange({
|
||
...form,
|
||
timezone: event.target.value,
|
||
})}
|
||
/>
|
||
</label>
|
||
<button
|
||
className="inspector-secondary-button"
|
||
type="button"
|
||
disabled={busy}
|
||
onClick={onPreview}
|
||
>
|
||
预览未来 5 次
|
||
</button>
|
||
{cronResult && (
|
||
<ol className="cron-preview-list">
|
||
{cronResult.occurrences.map((item) => (
|
||
<li key={item.utc_time}>
|
||
{new Date(item.local_time).toLocaleString("zh-CN", {
|
||
hour12: false,
|
||
})}
|
||
</li>
|
||
))}
|
||
</ol>
|
||
)}
|
||
</>
|
||
)}
|
||
</section>
|
||
|
||
<section>
|
||
<h3>执行策略</h3>
|
||
<div className="inspector-grid">
|
||
<label>
|
||
<span>最大并发</span>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max="64"
|
||
value={form.maxConcurrency}
|
||
onChange={(event) => onChange({
|
||
...form,
|
||
maxConcurrency: event.target.value,
|
||
})}
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span>失败策略</span>
|
||
<select
|
||
value={form.failurePolicy}
|
||
onChange={(event) => onChange({
|
||
...form,
|
||
failurePolicy: event.target.value as "stop" | "continue",
|
||
})}
|
||
>
|
||
<option value="stop">停止后续</option>
|
||
<option value="continue">继续执行</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<div className="schedule-next-run">
|
||
<span>下一次执行</span>
|
||
<strong>{formatTime(schedule.next_run_at)}</strong>
|
||
</div>
|
||
</section>
|
||
|
||
<button
|
||
className="inspector-primary-button"
|
||
type="button"
|
||
disabled={busy}
|
||
onClick={onSave}
|
||
>
|
||
保存调度属性
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
function NodeInspector({
|
||
node,
|
||
form,
|
||
busy,
|
||
onChange,
|
||
onSave,
|
||
onDelete,
|
||
}: {
|
||
node: ScheduleNode;
|
||
form: NodeForm;
|
||
busy: boolean;
|
||
onChange: (form: NodeForm) => void;
|
||
onSave: () => void;
|
||
onDelete: () => void;
|
||
}) {
|
||
return (
|
||
<div className="schedule-inspector">
|
||
<section className="node-inspector-title">
|
||
<span className={`artifact-card__icon artifact-card__icon--${node.version.script_type}`}>
|
||
<Icon
|
||
name={node.version.script_type === "notebook" ? "notebook" : "python"}
|
||
size={17}
|
||
/>
|
||
</span>
|
||
<div>
|
||
<small>节点配置</small>
|
||
<strong>{node.node_key}</strong>
|
||
</div>
|
||
</section>
|
||
<section>
|
||
<h3>稳定版本</h3>
|
||
<div className="version-readonly">
|
||
<strong>{node.version.script_name}</strong>
|
||
<span>{node.version.version_label}</span>
|
||
<small>versions_id: {node.versions_id}</small>
|
||
<small>SHA-256: {shortHash(node.version.content_hash)}</small>
|
||
</div>
|
||
</section>
|
||
<section>
|
||
<h3>节点信息</h3>
|
||
<label>
|
||
<span>节点名称</span>
|
||
<input
|
||
value={form.nodeName}
|
||
onChange={(event) => onChange({
|
||
...form,
|
||
nodeName: event.target.value,
|
||
})}
|
||
/>
|
||
</label>
|
||
<div className="inspector-grid">
|
||
<label>
|
||
<span>超时(秒)</span>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={form.timeoutSeconds}
|
||
onChange={(event) => onChange({
|
||
...form,
|
||
timeoutSeconds: event.target.value,
|
||
})}
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span>重试次数</span>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
max="10"
|
||
value={form.retryCount}
|
||
onChange={(event) => onChange({
|
||
...form,
|
||
retryCount: event.target.value,
|
||
})}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<label>
|
||
<span>重试间隔(秒)</span>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
value={form.retryIntervalSec}
|
||
onChange={(event) => onChange({
|
||
...form,
|
||
retryIntervalSec: event.target.value,
|
||
})}
|
||
/>
|
||
</label>
|
||
</section>
|
||
<section>
|
||
<h3>运行参数</h3>
|
||
<label>
|
||
<span>arguments_json</span>
|
||
<textarea
|
||
className="json-editor"
|
||
rows={5}
|
||
spellCheck={false}
|
||
value={form.argumentsJson}
|
||
onChange={(event) => onChange({
|
||
...form,
|
||
argumentsJson: event.target.value,
|
||
})}
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span>env_refs_json</span>
|
||
<textarea
|
||
className="json-editor"
|
||
rows={4}
|
||
spellCheck={false}
|
||
value={form.envRefsJson}
|
||
onChange={(event) => onChange({
|
||
...form,
|
||
envRefsJson: event.target.value,
|
||
})}
|
||
/>
|
||
</label>
|
||
</section>
|
||
<div className="node-inspector-actions">
|
||
<button
|
||
className="inspector-primary-button"
|
||
type="button"
|
||
disabled={busy}
|
||
onClick={onSave}
|
||
>
|
||
保存节点
|
||
</button>
|
||
<button
|
||
className="inspector-danger-button"
|
||
type="button"
|
||
disabled={busy}
|
||
onClick={onDelete}
|
||
>
|
||
删除节点
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
function withSuppressedError(action: () => Promise<void>): void {
|
||
void action().catch(() => undefined);
|
||
}
|