Files
model-platform/frontend/app/features/schedules/state/listSlice.ts
T

660 lines
22 KiB
TypeScript

// List slice: schedule list, artifact list, current schedule, loading/busy flags,
// and most mutation/loader actions. Filter inputs (scheduleKeyword/artifactKeyword)
// live in canvasSlice since they bind to the left-panel UI. Cross-slice actions
// live here when their primary write is to LIST fields; they reach into
// CANVAS / DIALOG / RUNS via `get()`.
import { ApiRequestError, type Schedule, type ScheduleArtifact, type ScheduleEdge, type ScheduleNode } from "../../../services/api";
import type { StateCreator } from "zustand";
import {
applyPositionDrafts,
applyServerUpdatedSchedule,
artifactNodeKey,
notify,
parseObject,
requireApi,
setApiOnline,
scheduleToForm,
withMutation,
} from "./helpers";
import {
PYTHON_VERSION_OPTIONS,
type NodeForm,
type ScheduleForm,
} from "./types";
import type { SchedulesStore } from "./useSchedulesStore";
// ---- State ----
export type ListSliceState = {
schedules: Schedule[];
artifacts: ScheduleArtifact[];
schedule: Schedule | null;
loading: boolean;
busy: string | null;
};
// ---- Actions ----
export type ListSliceActions = {
// pure setters
setSchedule: (
value: Schedule | null | ((current: Schedule | null) => Schedule | null),
) => void;
setSchedules: (
value: Schedule[] | ((current: Schedule[]) => Schedule[]),
) => void;
setArtifacts: (
value: ScheduleArtifact[]
| ((current: ScheduleArtifact[]) => ScheduleArtifact[]),
) => void;
setLoading: (loading: boolean) => void;
setBusy: (busy: string | null) => void;
// data loaders
loadInitial: () => Promise<void>;
refreshLists: (preferredScheduleId?: string | null) => Promise<void>;
chooseSchedule: (scheduleId: string) => Promise<void>;
// mutations
addSchedule: (name: string) => Promise<void>;
removeSchedule: (target?: Schedule) => Promise<void>;
renameSchedule: (target: Schedule) => Promise<void>;
removeArtifact: (artifact: ScheduleArtifact) => Promise<void>;
saveSchedule: () => Promise<void>;
runNow: () => Promise<void>;
addArtifactAt: (
artifact: ScheduleArtifact,
positionX: number,
positionY: number,
) => Promise<void>;
saveNode: (selectedNode: ScheduleNode | null) => Promise<void>;
removeNode: (target?: ScheduleNode) => Promise<void>;
removeEdge: (target?: ScheduleEdge) => Promise<void>;
checkDag: () => Promise<void>;
connectTo: (targetNodeId: string) => Promise<void>;
};
// `SchedulesStore` is the combined state+actions of all slices, so `get()`
// returns a fully typed snapshot — no `any` propagation.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const createListSlice: StateCreator<SchedulesStore, [], [], ListSliceState & ListSliceActions> = (set, get) => {
// ---- initial state ----
const state: ListSliceState = {
schedules: [],
artifacts: [],
schedule: null,
loading: true,
busy: null,
};
return {
...state,
// ---- pure setters ----
setSchedule: (value) =>
set((s: any) => ({
schedule:
typeof value === "function"
? (value as (current: Schedule | null) => Schedule | null)(s.schedule)
: value,
})),
setSchedules: (value) =>
set((s: any) => ({
schedules:
typeof value === "function"
? (value as (current: Schedule[]) => Schedule[])(s.schedules)
: value,
})),
setArtifacts: (value) =>
set((s: any) => ({
artifacts:
typeof value === "function"
? (value as (current: ScheduleArtifact[]) => ScheduleArtifact[])(
s.artifacts,
)
: value,
})),
setLoading: (loading) => set({ loading }),
setBusy: (busy) => set({ busy }),
// ---- data loaders ----
loadInitial: async () => {
const api = requireApi();
let cancelled = false;
set({ loading: true });
try {
const [scheduleItems, artifactItems] = await Promise.all([
api.listSchedules(),
api.listScheduleArtifacts(),
]);
if (cancelled) return;
set({ schedules: scheduleItems, artifacts: artifactItems });
if (scheduleItems[0]) {
const detail = await api.getSchedule(scheduleItems[0].schedule_id);
if (!cancelled) {
set({ schedule: applyPositionDrafts(detail) });
}
}
setApiOnline(true);
} catch (error) {
if (cancelled) return;
setApiOnline(false);
notify({
tone: "error",
message: error instanceof Error ? error.message : "调度数据加载失败",
});
} finally {
if (!cancelled) set({ loading: false });
}
},
refreshLists: async (preferredScheduleId) => {
const api = requireApi();
const [scheduleItems, artifactItems] = await Promise.all([
api.listSchedules(),
api.listScheduleArtifacts(),
]);
set({ schedules: scheduleItems, artifacts: artifactItems });
const targetId =
preferredScheduleId
?? get().schedule?.schedule_id
?? scheduleItems[0]?.schedule_id
?? null;
if (!targetId) {
set({ schedule: null });
return;
}
const detail = await api.getSchedule(targetId);
set({ schedule: applyPositionDrafts(detail) });
},
chooseSchedule: async (scheduleId) => {
const api = requireApi();
const current = get().schedule;
if (scheduleId === current?.schedule_id || get().busy) return;
set({ busy: "load-schedule" });
get().clearAllPositionDrafts();
set({
selectedNodeId: null,
selectedEdgeId: null,
linkSourceId: null,
cronResult: null,
runs: [],
runsLoading: false,
});
try {
set({ schedule: await api.getSchedule(scheduleId) });
setApiOnline(true);
} catch (error) {
const { handleError } = await import("./helpers");
await handleError(get, error, "调度详情加载失败");
} finally {
set({ busy: null });
}
},
// ---- mutations ----
addSchedule: async (name) => {
const api = requireApi();
const scheduleName = name.trim();
if (get().busy || !scheduleName) return;
set({ busy: "create-schedule" });
try {
const created = await api.createSchedule({
schedule_name: scheduleName,
description: "在画布中拖入稳定版本并配置执行顺序",
trigger_type: "manual",
timezone: "Asia/Shanghai",
enabled: false,
});
set((s: any) => ({
schedules: [created, ...s.schedules],
schedule: created,
createDialogOpen: false,
newScheduleName: "",
}));
get().clearAllPositionDrafts();
set({ selectedNodeId: null, selectedEdgeId: null });
notify({ tone: "success", message: "调度方案已创建" });
} catch (error) {
const { handleError } = await import("./helpers");
await handleError(get, error, "创建调度失败");
} finally {
set({ busy: null });
}
},
removeSchedule: async (target) => {
const api = requireApi();
const target_ = target ?? get().schedule;
if (!target_ || get().busy) return;
set({ contextMenu: null });
if (!window.confirm(`确定删除调度"${target_.schedule_name}"吗?`)) return;
set({ busy: "delete-schedule" });
try {
await api.deleteSchedule(target_.schedule_id, target_.workflow_version);
const remaining = get().schedules.filter(
(item) => item.schedule_id !== target_.schedule_id,
);
set({ schedules: remaining });
if (get().schedule?.schedule_id === target_.schedule_id) {
get().clearAllPositionDrafts();
set({
schedule: null,
selectedNodeId: null,
selectedEdgeId: null,
});
if (remaining[0]) {
const detail = await api.getSchedule(remaining[0].schedule_id);
set({ schedule: detail });
}
}
notify({ tone: "success", message: "调度方案已删除" });
} catch (error) {
const { handleError } = await import("./helpers");
await handleError(get, error, "删除调度失败");
} finally {
set({ busy: null });
}
},
renameSchedule: async (target) => {
const api = requireApi();
if (get().busy) return;
set({ contextMenu: null });
const scheduleName = window
.prompt("请输入新的调度方案名称", target.schedule_name)
?.trim();
if (!scheduleName || scheduleName === target.schedule_name) return;
set({ busy: "rename-schedule" });
try {
const updated = await api.updateSchedule(target.schedule_id, {
workflow_version: target.workflow_version,
schedule_name: scheduleName,
});
set((s: any) => ({
schedules: s.schedules.map((item: Schedule) =>
item.schedule_id === updated.schedule_id
? { ...updated, nodes: [], edges: [] }
: item
),
schedule:
s.schedule?.schedule_id === updated.schedule_id
? updated
: s.schedule,
}));
notify({ tone: "success", message: "调度方案已改名" });
} catch (error) {
const { handleError } = await import("./helpers");
await handleError(get, error, "调度方案改名失败");
} finally {
set({ busy: null });
}
},
removeArtifact: async (artifact) => {
const api = requireApi();
if (get().busy) return;
set({ contextMenu: null });
if (
!window.confirm(
`确定将"${artifact.script_name} ${artifact.version_label}"移出调度列表吗?\n`
+ "稳定版本本身和历史运行记录不会被删除。",
)
) return;
set({ busy: "delete-artifact" });
try {
await api.hideScheduleArtifact(artifact.versions_id);
set((s: any) => ({
artifacts: s.artifacts.filter(
(item: ScheduleArtifact) => item.versions_id !== artifact.versions_id,
),
}));
notify({
tone: "success",
message: "已移出调度列表,稳定版本和历史记录保持不变",
});
} catch (error) {
const { handleError } = await import("./helpers");
await handleError(get, error, "移出调度列表失败");
} finally {
set({ busy: null });
}
},
saveSchedule: async () => {
const api = requireApi();
const state_ = get();
const { schedule, scheduleForm } = state_;
if (!schedule || state_.busy) return;
const scheduleFormTyped = scheduleForm as ScheduleForm;
const maxConcurrency = Number(scheduleFormTyped.maxConcurrency);
if (!scheduleFormTyped.scheduleName.trim()) {
notify({ tone: "error", message: "调度名称不能为空" });
return;
}
if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1) {
notify({ tone: "error", message: "最大并发数必须是正整数" });
return;
}
set({ busy: "save-schedule" });
let updated: Schedule = schedule;
try {
const { positionDrafts } = await import("./helpers");
for (const [nodeId, position] of positionDrafts.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: scheduleFormTyped.scheduleName.trim(),
description: scheduleFormTyped.description.trim() || null,
trigger_type: scheduleFormTyped.triggerType,
cron_expression:
scheduleFormTyped.triggerType === "cron"
? scheduleFormTyped.cronExpression.trim()
: null,
timezone: scheduleFormTyped.timezone.trim(),
enabled: scheduleFormTyped.enabled,
max_concurrency: maxConcurrency,
failure_policy: scheduleFormTyped.failurePolicy,
});
get().clearAllPositionDrafts();
set((s: any) => ({
schedule: updated,
schedules: s.schedules.map((item: Schedule) =>
item.schedule_id === updated.schedule_id
? { ...updated, nodes: [], edges: [] }
: item
),
}));
notify({ tone: "success", message: "调度配置已保存" });
} catch (error) {
const localDraft = applyPositionDrafts(updated);
set({ schedule: localDraft });
const { handleError } = await import("./helpers");
await handleError(get, error, "调度配置保存失败");
} finally {
set({ busy: null });
}
},
runNow: async () => {
const api = requireApi();
const state_ = get();
const { schedule, positionDraftCount } = state_;
if (!schedule || state_.busy) return;
if (positionDraftCount > 0) {
notify({
tone: "info",
message: "还有节点位置未保存,请先点击'保存配置'再运行",
});
return;
}
if (!schedule.dag_validation.valid || schedule.nodes.length === 0) {
notify({
tone: "error",
message: "当前调度必须包含有效的非空 DAG 才能运行",
});
return;
}
set({ busy: "run-now" });
try {
const created = await api.runScheduleNow(schedule.schedule_id);
set((s: any) => ({
runs: [
created,
...s.runs.filter((item: { run_id: string }) => item.run_id !== created.run_id),
].slice(0, 20),
schedule:
s.schedule
? { ...s.schedule, last_run_at: created.queued_at }
: s.schedule,
schedules: s.schedules.map((item: Schedule) =>
item.schedule_id === schedule.schedule_id
? { ...item, last_run_at: created.queued_at }
: item
),
}));
notify({
tone: "success",
message: `运行 ${created.run_id.slice(-8)} 已进入队列`,
});
} catch (error) {
const { handleError } = await import("./helpers");
await handleError(get, error, "立即运行失败");
} finally {
set({ busy: null });
}
},
addArtifactAt: async (artifact, positionX, positionY) => {
const state_ = get();
const { schedule } = state_;
if (!schedule) {
notify({ tone: "info", message: "请先新建或选择一个调度方案" });
return;
}
const nodeKey = artifactNodeKey(artifact, schedule);
const updated = await withMutation(
set,
get,
"add-node",
() => requireApi().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: {},
python_version: "3.12",
}),
`${artifact.script_name} 已加入画布`,
);
if (updated) {
const created = updated.nodes.find((item) => item.node_key === nodeKey);
set({ selectedNodeId: created?.node_id ?? null });
}
},
saveNode: async (selectedNode) => {
const api = requireApi();
const state_ = get();
const { schedule, nodeForm } = state_;
if (!schedule || !selectedNode) return;
try {
const nodeFormTyped = nodeForm as NodeForm;
const timeoutSeconds = Number(nodeFormTyped.timeoutSeconds);
const retryCount = Number(nodeFormTyped.retryCount);
const retryIntervalSec = Number(nodeFormTyped.retryIntervalSec);
if (!nodeFormTyped.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("重试间隔必须是非负整数");
}
if (!PYTHON_VERSION_OPTIONS.includes(nodeFormTyped.pythonVersion)) {
throw new Error("Python 版本必须是 3.8 / 3.10 / 3.12");
}
const argumentsJson = parseObject(nodeFormTyped.argumentsJson, "运行参数");
const rawEnv = parseObject(nodeFormTyped.envRefsJson, "环境引用");
const envRefsJson = Object.fromEntries(
Object.entries(rawEnv).map(([key, value]) => {
if (typeof value !== "string") {
throw new Error("环境引用的值必须是字符串");
}
return [key, value];
}),
);
await withMutation(
set,
get,
"save-node",
() =>
api.updateScheduleNode(
schedule.schedule_id,
selectedNode.node_id,
{
workflow_version: schedule.workflow_version,
node_name: nodeFormTyped.nodeName.trim(),
timeout_seconds: timeoutSeconds,
retry_count: retryCount,
retry_interval_sec: retryIntervalSec,
arguments_json: argumentsJson,
env_refs_json: envRefsJson,
python_version: nodeFormTyped.pythonVersion,
},
),
"节点配置已保存",
);
} catch (error) {
const { handleError } = await import("./helpers");
await handleError(get, error, "节点配置保存失败");
}
},
removeNode: async (target) => {
const api = requireApi();
const state_ = get();
const { schedule, selectedNodeId } = state_;
const node = target
?? schedule?.nodes.find((item) => item.node_id === selectedNodeId)
?? null;
if (!schedule || !node || state_.busy) return;
set({ contextMenu: null });
if (!window.confirm(`确定删除节点"${node.node_name}"吗?`)) return;
set({ busy: "delete-node" });
try {
let updated: Schedule;
try {
// 先走普通删除:没有历史记录时不额外打扰用户。
updated = await api.deleteScheduleNode(
schedule.schedule_id,
node.node_id,
schedule.workflow_version,
);
} catch (error) {
const requiresHistoryConfirmation =
error instanceof ApiRequestError
&& error.status === 409
&& error.code === "node_execution_history_exists";
if (!requiresHistoryConfirmation) throw error;
if (!window.confirm("该节点有运行日志,是否一并删除?")) return;
updated = await api.deleteScheduleNode(
schedule.schedule_id,
node.node_id,
schedule.workflow_version,
{ delete_execution_history: true },
);
}
applyServerUpdatedSchedule(set, updated);
if (selectedNodeId === node.node_id) set({ selectedNodeId: null });
notify({ tone: "success", message: "节点及其运行日志已删除" });
} catch (error) {
const { handleError } = await import("./helpers");
await handleError(get, error, "删除节点失败");
} finally {
set({ busy: null });
}
},
removeEdge: async (target) => {
const api = requireApi();
const state_ = get();
const { schedule, selectedEdgeId } = state_;
const edge = target
?? schedule?.edges.find((item) => item.edge_id === selectedEdgeId)
?? null;
if (!schedule || !edge || state_.busy) return;
set({ contextMenu: null });
const updated = await withMutation(
set,
get,
"delete-edge",
() =>
api.deleteScheduleEdge(
schedule.schedule_id,
edge.edge_id,
schedule.workflow_version,
),
"连线已删除",
);
if (updated && selectedEdgeId === edge.edge_id) {
set({ selectedEdgeId: null });
}
},
checkDag: async () => {
const api = requireApi();
const { schedule } = get();
if (!schedule || get().busy) return;
set({ busy: "validate" });
try {
const result = await api.validateSchedule(schedule.schedule_id);
set((s: any) => ({
schedule: s.schedule
? { ...s.schedule, dag_validation: result }
: s.schedule,
}));
notify({
tone: result.valid ? "success" : "error",
message: result.valid
? "当前 DAG 通过校验"
: "当前 DAG 存在校验问题",
});
} catch (error) {
const { handleError } = await import("./helpers");
await handleError(get, error, "DAG 校验失败");
} finally {
set({ busy: null });
}
},
connectTo: async (targetNodeId) => {
const state_ = get();
const { schedule, linkSourceId } = state_;
if (!schedule || !linkSourceId || state_.busy) return;
if (linkSourceId === targetNodeId) {
set({ linkSourceId: null });
notify({ tone: "info", message: "已取消连线" });
return;
}
const sourceId = linkSourceId;
set({ linkSourceId: null });
await withMutation(
set,
get,
"create-edge",
() =>
requireApi().createScheduleEdge(schedule.schedule_id, {
workflow_version: schedule.workflow_version,
source_node_id: sourceId,
target_node_id: targetNodeId,
}),
"节点连线已创建",
);
},
};
};
// (avoid unused-import warning for scheduleToForm — referenced for parity with original store)
void scheduleToForm;