From 2ed8d7b2cc44dff37271bd7ef3f79d4cfd350af4 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:48:29 +0800 Subject: [PATCH] refactor: useSchedulesStore.ts --- .../app/features/schedules/NodeInspector.tsx | 2 +- .../app/features/schedules/RunHistory.tsx | 2 +- .../app/features/schedules/SchedulePage.tsx | 4 +- .../schedules/hooks/useCanvasNodeDrag.ts | 2 +- .../schedules/hooks/useContextMenuDismiss.ts | 2 +- .../features/schedules/state/canvasSlice.ts | 108 ++ .../features/schedules/state/dialogSlice.ts | 78 + .../app/features/schedules/state/helpers.ts | 204 +++ .../app/features/schedules/state/listSlice.ts | 659 +++++++++ .../app/features/schedules/state/logsSlice.ts | 118 ++ .../app/features/schedules/state/runsSlice.ts | 204 +++ .../schedules/state/schedulesStore.ts | 1251 ----------------- .../app/features/schedules/state/types.ts | 92 ++ .../schedules/state/useSchedulesStore.ts | 110 ++ frontend/app/features/schedules/utils.ts | 2 +- frontend/app/routes/platform.tsx | 2 +- 16 files changed, 1581 insertions(+), 1259 deletions(-) create mode 100644 frontend/app/features/schedules/state/canvasSlice.ts create mode 100644 frontend/app/features/schedules/state/dialogSlice.ts create mode 100644 frontend/app/features/schedules/state/helpers.ts create mode 100644 frontend/app/features/schedules/state/listSlice.ts create mode 100644 frontend/app/features/schedules/state/logsSlice.ts create mode 100644 frontend/app/features/schedules/state/runsSlice.ts delete mode 100644 frontend/app/features/schedules/state/schedulesStore.ts create mode 100644 frontend/app/features/schedules/state/types.ts create mode 100644 frontend/app/features/schedules/state/useSchedulesStore.ts diff --git a/frontend/app/features/schedules/NodeInspector.tsx b/frontend/app/features/schedules/NodeInspector.tsx index 19f12d4..9475a7a 100644 --- a/frontend/app/features/schedules/NodeInspector.tsx +++ b/frontend/app/features/schedules/NodeInspector.tsx @@ -1,7 +1,7 @@ import { type ScheduleNode } from "../../services/api"; import { BookText, FileCode2 } from "lucide-react"; import { shortHash } from "./utils"; -import { PYTHON_VERSION_OPTIONS, type NodeForm } from "./state/schedulesStore"; +import { PYTHON_VERSION_OPTIONS, type NodeForm } from "./state/types"; export function NodeInspector({ node, diff --git a/frontend/app/features/schedules/RunHistory.tsx b/frontend/app/features/schedules/RunHistory.tsx index 3e9bb6c..dbc0aee 100644 --- a/frontend/app/features/schedules/RunHistory.tsx +++ b/frontend/app/features/schedules/RunHistory.tsx @@ -6,7 +6,7 @@ import { import { useApi } from "../../context/AuthContext"; import { RefreshCw, X } from "lucide-react"; -import { useSchedulesStore } from "./state/schedulesStore"; +import { useSchedulesStore } from "./state/useSchedulesStore"; import { formatDuration, formatSize, diff --git a/frontend/app/features/schedules/SchedulePage.tsx b/frontend/app/features/schedules/SchedulePage.tsx index 859cef4..bb656b5 100644 --- a/frontend/app/features/schedules/SchedulePage.tsx +++ b/frontend/app/features/schedules/SchedulePage.tsx @@ -39,8 +39,8 @@ import { useContextMenuDismiss } from "./hooks/useContextMenuDismiss"; import { EMPTY_NODE_FORM, type ScheduleContextMenu, -} from "./state/schedulesStore"; -import { useSchedulesStore } from "./state/schedulesStore"; +} from "./state/types"; +import { useSchedulesStore } from "./state/useSchedulesStore"; import { edgePath, nodeToForm, scheduleToForm } from "./utils"; type Notice = { diff --git a/frontend/app/features/schedules/hooks/useCanvasNodeDrag.ts b/frontend/app/features/schedules/hooks/useCanvasNodeDrag.ts index 5dc6a6d..54c4aa0 100644 --- a/frontend/app/features/schedules/hooks/useCanvasNodeDrag.ts +++ b/frontend/app/features/schedules/hooks/useCanvasNodeDrag.ts @@ -1,5 +1,5 @@ import { useRef, type PointerEvent as ReactPointerEvent } from "react"; -import { useSchedulesStore } from "../state/schedulesStore"; +import { useSchedulesStore } from "../state/useSchedulesStore"; import type { ScheduleNode } from "../../../services/api"; type DragState = { diff --git a/frontend/app/features/schedules/hooks/useContextMenuDismiss.ts b/frontend/app/features/schedules/hooks/useContextMenuDismiss.ts index 672092d..016cfd4 100644 --- a/frontend/app/features/schedules/hooks/useContextMenuDismiss.ts +++ b/frontend/app/features/schedules/hooks/useContextMenuDismiss.ts @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import { useSchedulesStore } from "../state/schedulesStore"; +import { useSchedulesStore } from "../state/useSchedulesStore"; /** * 监听全局 pointerdown / blur / resize / scroll / Escape,关闭当前打开的右键菜单。 diff --git a/frontend/app/features/schedules/state/canvasSlice.ts b/frontend/app/features/schedules/state/canvasSlice.ts new file mode 100644 index 0000000..840ba03 --- /dev/null +++ b/frontend/app/features/schedules/state/canvasSlice.ts @@ -0,0 +1,108 @@ +// Canvas slice: selection, edit forms, context menu, left-panel filter inputs, +// position-draft reactive mirror. + +import type { StateCreator } from "zustand"; + +import type { NodePositionDraft, ScheduleContextMenu, ScheduleForm, NodeForm } from "./types"; +import { positionDrafts, clearPositionDrafts } from "./helpers"; +import type { SchedulesStore } from "./useSchedulesStore"; + +// ---- State ---- + +export type CanvasSliceState = { + selectedNodeId: string | null; + selectedEdgeId: string | null; + linkSourceId: string | null; + scheduleKeyword: string; + artifactKeyword: string; + scheduleForm: ScheduleForm; + nodeForm: NodeForm; + contextMenu: ScheduleContextMenu | null; + positionDraftCount: number; +}; + +// ---- Actions ---- + +export type CanvasSliceActions = { + // pure setters + setSelectedNodeId: (id: string | null) => void; + setSelectedEdgeId: (id: string | null) => void; + setLinkSourceId: (id: string | null) => void; + setScheduleKeyword: (k: string) => void; + setArtifactKeyword: (k: string) => void; + setScheduleForm: (form: ScheduleForm) => void; + setNodeForm: (form: NodeForm) => void; + setContextMenu: (menu: ScheduleContextMenu | null) => void; + closeContextMenu: () => void; + + // drag helpers + setPositionDraft: (nodeId: string, draft: NodePositionDraft) => void; + setPositionDraftCount: (count: number) => void; + getPositionDrafts: () => Map; + clearAllPositionDrafts: () => void; + prunePositionDrafts: (validNodeIds: Set) => 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 createCanvasSlice: StateCreator = (set, get) => ({ + // ---- initial state ---- + selectedNodeId: null, + selectedEdgeId: null, + linkSourceId: null, + scheduleKeyword: "", + artifactKeyword: "", + scheduleForm: { + scheduleName: "", + description: "", + triggerType: "manual", + cronExpression: "", + timezone: "Asia/Shanghai", + enabled: false, + maxConcurrency: "1", + failurePolicy: "stop", + }, + nodeForm: { + nodeName: "", + timeoutSeconds: "600", + retryCount: "0", + retryIntervalSec: "5", + argumentsJson: "{}", + envRefsJson: "{}", + pythonVersion: "3.12", + }, + contextMenu: null, + positionDraftCount: 0, + + // ---- pure setters ---- + + setSelectedNodeId: (id) => set({ selectedNodeId: id }), + setSelectedEdgeId: (id) => set({ selectedEdgeId: id }), + setLinkSourceId: (id) => set({ linkSourceId: id }), + setScheduleKeyword: (k) => set({ scheduleKeyword: k }), + setArtifactKeyword: (k) => set({ artifactKeyword: k }), + setScheduleForm: (form) => set({ scheduleForm: form }), + setNodeForm: (form) => set({ nodeForm: form }), + setContextMenu: (menu) => set({ contextMenu: menu }), + closeContextMenu: () => set({ contextMenu: null }), + + // ---- drag helpers ---- + + setPositionDraft: (nodeId, draft) => { + positionDrafts.set(nodeId, draft); + set({ positionDraftCount: positionDrafts.size }); + }, + setPositionDraftCount: (count) => set({ positionDraftCount: count }), + getPositionDrafts: () => positionDrafts, + clearAllPositionDrafts: () => { + clearPositionDrafts(); + set({ positionDraftCount: 0 }); + }, + prunePositionDrafts: (validNodeIds) => { + for (const nodeId of [...positionDrafts.keys()]) { + if (!validNodeIds.has(nodeId)) positionDrafts.delete(nodeId); + } + set({ positionDraftCount: positionDrafts.size }); + }, +}); diff --git a/frontend/app/features/schedules/state/dialogSlice.ts b/frontend/app/features/schedules/state/dialogSlice.ts new file mode 100644 index 0000000..8b899ac --- /dev/null +++ b/frontend/app/features/schedules/state/dialogSlice.ts @@ -0,0 +1,78 @@ +// Dialog slice: create-schedule dialog state + cron preview. +// Pure setters + small mutations that read/write only dialog-owned fields. + +import type { CronPreview } from "../../../services/api"; +import type { StateCreator } from "zustand"; + +import { handleError, notify, requireApi } from "./helpers"; +import type { SchedulesStore } from "./useSchedulesStore"; + +// ---- State ---- + +export type DialogSliceState = { + createDialogOpen: boolean; + newScheduleName: string; + cronResult: CronPreview | null; +}; + +// ---- Actions ---- + +export type DialogSliceActions = { + // pure setters + setCreateDialogOpen: (open: boolean) => void; + setNewScheduleName: (name: string) => void; + setCronResult: (result: CronPreview | null) => void; + + // mutations + openCreateDialog: () => void; + runCronPreview: () => Promise; +}; + +// `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 createDialogSlice: StateCreator = (set, get) => ({ + // ---- initial state ---- + createDialogOpen: false, + newScheduleName: "", + cronResult: null, + + // ---- pure setters ---- + + setCreateDialogOpen: (open) => set({ createDialogOpen: open }), + setNewScheduleName: (name) => set({ newScheduleName: name }), + setCronResult: (result) => set({ cronResult: result }), + + // ---- mutations ---- + + openCreateDialog: () => { + if (get().busy) return; + set((s: any) => ({ + contextMenu: null, + newScheduleName: `新建调度 ${s.schedules.length + 1}`, + createDialogOpen: true, + })); + }, + + runCronPreview: async () => { + const api = requireApi(); + const { scheduleForm } = get(); + if (scheduleForm.triggerType !== "cron") return; + if (get().busy) return; + set({ busy: "cron-preview" }); + try { + const result = await api.previewCron({ + cron_expression: scheduleForm.cronExpression.trim(), + timezone: scheduleForm.timezone.trim(), + count: 5, + }); + set({ cronResult: result }); + notify({ tone: "success", message: "Cron 表达式校验通过" }); + } catch (error) { + set({ cronResult: null }); + await handleError(get, error, "Cron 预览失败"); + } finally { + set({ busy: null }); + } + }, +}); \ No newline at end of file diff --git a/frontend/app/features/schedules/state/helpers.ts b/frontend/app/features/schedules/state/helpers.ts new file mode 100644 index 0000000..f452cdb --- /dev/null +++ b/frontend/app/features/schedules/state/helpers.ts @@ -0,0 +1,204 @@ +// ---- Module-level non-reactive holders ---- + +import { ApiRequestError, type Schedule, type ScheduleNode, type WorkspaceBoundApi } from "../../../services/api"; + +import { useScriptWorkspaceStore } from "../../platform/state/scriptWorkspaceStore"; +import { useUiStore } from "../../platform/state/uiStore"; + +import type { NodeForm, NodePositionDraft, Notice, ScheduleForm } from "./types"; +import type { SchedulesStore } from "./useSchedulesStore"; + +// ---- API binding (was module-level in the monolithic store) ---- + +let _api: WorkspaceBoundApi | null = null; + +export const bindSchedulesApi = (api: WorkspaceBoundApi | null) => { + _api = api; +}; + +export function requireApi(): WorkspaceBoundApi { + if (!_api) throw new Error("schedules API 未绑定"); + return _api; +} + +export function notify(notice: Notice): void { + useUiStore.getState().pushToast(notice); +} + +export function setApiOnline(online: boolean): void { + useScriptWorkspaceStore.getState().setApiOnline(online); +} + +// ---- Drag-draft singleton (canvasSlice logically owns it but several slices need access) ---- + +export const positionDrafts = new Map(); + +export function applyPositionDrafts(schedule: Schedule): Schedule { + if (positionDrafts.size === 0) return schedule; + return { + ...schedule, + nodes: schedule.nodes.map((node) => { + const draft = positionDrafts.get(node.node_id); + return draft ? { ...node, ...draft } : node; + }), + }; +} + +export function clearPositionDrafts(): void { + positionDrafts.clear(); +} + +// ---- Pure helpers (stateless) ---- + +export function scheduleToForm(schedule: Schedule): ScheduleForm { + return { + scheduleName: schedule.schedule_name, + description: schedule.description ?? "", + triggerType: schedule.trigger_type, + cronExpression: schedule.cron_expression ?? "", + timezone: schedule.timezone, + enabled: schedule.enabled, + maxConcurrency: String(schedule.max_concurrency), + failurePolicy: schedule.failure_policy, + }; +} + +export 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), + pythonVersion: (node.python_version ?? "3.12") as NodeForm["pythonVersion"], + }; +} + +export function parseObject(text: string, label: string): Record { + const trimmed = text.trim(); + if (!trimmed) return {}; + try { + const parsed = JSON.parse(trimmed); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`${label} 必须是 JSON 对象`); + } + return parsed as Record; + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error(`${label} JSON 格式不正确`); + } + throw error; + } +} + +/** + * 顶层 pure helper (artifactNodeKey needs schedule, so it's defined outside the store closure). + * Computes a unique `node_key` for an artifact within the given schedule, + * mirroring the heuristic that was originally at the bottom of the monolithic store. + */ +export function artifactNodeKey( + artifact: { script_name: string; versions_id: string }, + schedule: Schedule, +): string { + const ascii = artifact.script_name + .toLowerCase() + .replace(/[^a-z0-9_]+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 24); + 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; + for (let i = 2; i < 1000; i++) { + const candidate = `${base}_${i}`; + if (!existing.has(candidate)) return candidate; + } + return `${base}_${Date.now().toString(36)}`; +} + +// ---- Cross-slice store helpers ---- +// These were inner closures of the monolithic store body. They take set/get as +// arguments so any slice can call them without circular state-type imports. + +type ListFieldSlice = { + schedule: Schedule | null; + schedules: Schedule[]; +}; + +/** + * Generic error handler with 412 (concurrent update) awareness. + * Walks the store via the supplied `get()` so it works from any slice. + */ +export async function handleError( + get: () => SchedulesStore, + error: unknown, + fallback: string, +): Promise { + if (error instanceof ApiRequestError && error.status === 412) { + const currentId = get().schedule?.schedule_id; + if (currentId) { + get().refreshLists(currentId).catch(() => undefined); + } + notify({ tone: "error", message: "调度已被其他操作更新,已重新加载最新版本" }); + return; + } + notify({ + tone: "error", + message: error instanceof Error ? error.message : fallback, + }); +} + +/** + * Overlay pending drag drafts onto a server-updated schedule and write + * `schedule` + `schedules` (LIST) + `positionDraftCount` (CANVAS). + */ +export function applyServerUpdatedSchedule( + set: (partial: Partial | ((s: SchedulesStore) => Partial)) => void, + serverUpdated: Schedule, +): void { + const validNodeIds = new Set(serverUpdated.nodes.map((node) => node.node_id)); + for (const nodeId of [...positionDrafts.keys()]) { + if (!validNodeIds.has(nodeId)) positionDrafts.delete(nodeId); + } + const updated = applyPositionDrafts(serverUpdated); + set((state) => ({ + schedule: updated, + schedules: (() => { + const summary = { ...updated, nodes: [], edges: [] }; + const index = state.schedules.findIndex( + (item) => item.schedule_id === updated.schedule_id, + ); + if (index < 0) return [summary, ...state.schedules]; + return state.schedules.map((item) => + item.schedule_id === updated.schedule_id ? summary : item, + ); + })(), + positionDraftCount: positionDrafts.size, + })); +} + +/** + * Generic mutation wrapper: busy + try/catch + 412 + positionDraft cleanup + schedules list update. + */ +export async function withMutation( + set: (partial: Partial | ((s: SchedulesStore) => Partial)) => void, + get: () => SchedulesStore, + label: string, + action: () => Promise, + successMessage: string, +): Promise { + const api = requireApi(); + if (get().busy) return null; + set({ busy: label }); + try { + const serverUpdated = await action(); + applyServerUpdatedSchedule(set, serverUpdated); + notify({ tone: "success", message: successMessage }); + return serverUpdated; + } catch (error) { + await handleError(get, error, `${successMessage}失败`); + return null; + } finally { + set({ busy: null }); + } +} diff --git a/frontend/app/features/schedules/state/listSlice.ts b/frontend/app/features/schedules/state/listSlice.ts new file mode 100644 index 0000000..33f7715 --- /dev/null +++ b/frontend/app/features/schedules/state/listSlice.ts @@ -0,0 +1,659 @@ +// 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; + refreshLists: (preferredScheduleId?: string | null) => Promise; + chooseSchedule: (scheduleId: string) => Promise; + + // mutations + addSchedule: (name: string) => Promise; + removeSchedule: (target?: Schedule) => Promise; + renameSchedule: (target: Schedule) => Promise; + removeArtifact: (artifact: ScheduleArtifact) => Promise; + saveSchedule: () => Promise; + runNow: () => Promise; + addArtifactAt: ( + artifact: ScheduleArtifact, + positionX: number, + positionY: number, + ) => Promise; + saveNode: (selectedNode: ScheduleNode | null) => Promise; + removeNode: (target?: ScheduleNode) => Promise; + removeEdge: (target?: ScheduleEdge) => Promise; + checkDag: () => Promise; + connectTo: (targetNodeId: string) => Promise; +}; + +// `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 = (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; diff --git a/frontend/app/features/schedules/state/logsSlice.ts b/frontend/app/features/schedules/state/logsSlice.ts new file mode 100644 index 0000000..1964805 --- /dev/null +++ b/frontend/app/features/schedules/state/logsSlice.ts @@ -0,0 +1,118 @@ +// Logs slice: per-node-run log fetch + display state. +// toggleLog owns the open/closed toggle and lazily loads content from the +// signed URL on first open. + +import type { StateCreator } from "zustand"; + +import { requireApi } from "./helpers"; +import type { LogState } from "./types"; +import type { SchedulesStore } from "./useSchedulesStore"; + +// ---- State ---- + +export type LogsSliceState = { + openLogNodeRunIds: Set; + logStates: Record; +}; + +// ---- Actions ---- + +export type LogsSliceActions = { + // pure setters + setOpenLogNodeRunIds: ( + value: Set | ((current: Set) => Set), + ) => void; + setLogStates: ( + updater: (current: Record) => Record, + ) => void; + + // mutations + toggleLog: (runId: string, nodeRunId: string) => Promise; +}; + +// `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 createLogsSlice: StateCreator = (set, get) => ({ + // ---- initial state ---- + openLogNodeRunIds: new Set(), + logStates: {}, + + // ---- pure setters ---- + + setOpenLogNodeRunIds: (value) => + set((s: any) => ({ + openLogNodeRunIds: + typeof value === "function" + ? (value as (current: Set) => Set)( + s.openLogNodeRunIds, + ) + : value, + })), + setLogStates: (updater) => + set((s: any) => ({ logStates: updater(s.logStates) })), + + // ---- mutations ---- + + toggleLog: async (runId, nodeRunId) => { + const api = requireApi(); + const state = get(); + const open = state.openLogNodeRunIds; + if (open.has(nodeRunId)) { + const next = new Set(open); + next.delete(nodeRunId); + set({ openLogNodeRunIds: next }); + return; + } + const next = new Set(open); + next.add(nodeRunId); + set({ openLogNodeRunIds: next }); + if (state.logStates[nodeRunId]?.content) return; + set((s: any) => ({ + logStates: { + ...s.logStates, + [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(); + set((s: any) => ({ + logStates: { + ...s.logStates, + [nodeRunId]: { + loading: false, + content, + fileName: artifacts.log?.file_name ?? null, + error: null, + }, + }, + })); + } catch (error) { + set((s: any) => ({ + logStates: { + ...s.logStates, + [nodeRunId]: { + loading: false, + content: null, + fileName: null, + error: error instanceof Error ? error.message : "日志读取失败", + }, + }, + })); + } + }, +}); \ No newline at end of file diff --git a/frontend/app/features/schedules/state/runsSlice.ts b/frontend/app/features/schedules/state/runsSlice.ts new file mode 100644 index 0000000..09ef868 --- /dev/null +++ b/frontend/app/features/schedules/state/runsSlice.ts @@ -0,0 +1,204 @@ +// Runs slice: per-schedule run summaries, run detail expansion, artifact downloads. +// Cross-slice via `get()` for `schedule.schedule_id` consistency checks (refreshRuns) +// and `busy` (listSlice). + +import type { ScheduleRunDetail, ScheduleRunSummary } from "../../../services/api"; +import type { StateCreator } from "zustand"; + +import { handleError, requireApi, setApiOnline } from "./helpers"; +import type { SchedulesStore } from "./useSchedulesStore"; + +// ---- State ---- + +export type RunsSliceState = { + runs: ScheduleRunSummary[]; + runsLoading: boolean; + + expandedRunId: string | null; + runDetails: Record; + detailLoadingId: string | null; + detailErrors: Record; + + artifactBusyKey: string | null; + artifactErrors: Record; +}; + +// ---- Actions ---- + +export type RunsSliceActions = { + // pure setters + setRuns: ( + value: ScheduleRunSummary[] + | ((current: ScheduleRunSummary[]) => ScheduleRunSummary[]), + ) => void; + setRunsLoading: (loading: boolean) => void; + setRunDetails: ( + updater: (current: Record) => Record, + ) => void; + setDetailLoadingId: (id: string | null) => void; + setDetailErrors: ( + updater: (current: Record) => Record, + ) => void; + setArtifactBusyKey: (key: string | null) => void; + setArtifactErrors: ( + updater: (current: Record) => Record, + ) => void; + setExpandedRunId: ( + id: string | null | ((current: string | null) => string | null), + ) => void; + + // mutations / loaders + refreshRuns: (scheduleId: string, showLoading?: boolean) => Promise; + toggleRun: (runId: string) => void; + downloadResult: (runId: string, nodeRunId: string) => Promise; + downloadLog: (runId: string, nodeRunId: string) => Promise; +}; + +// `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 createRunsSlice: StateCreator = (set, get) => ({ + // ---- initial state ---- + runs: [], + runsLoading: false, + + expandedRunId: null, + runDetails: {}, + detailLoadingId: null, + detailErrors: {}, + artifactBusyKey: null, + artifactErrors: {}, + + // ---- pure setters ---- + + setRuns: (value) => + set((s: any) => ({ + runs: + typeof value === "function" + ? (value as (current: ScheduleRunSummary[]) => ScheduleRunSummary[])( + s.runs, + ) + : value, + })), + setRunsLoading: (loading) => set({ runsLoading: loading }), + setRunDetails: (updater) => + set((s: any) => ({ runDetails: updater(s.runDetails) })), + setDetailLoadingId: (id) => set({ detailLoadingId: id }), + setDetailErrors: (updater) => + set((s: any) => ({ detailErrors: updater(s.detailErrors) })), + setArtifactBusyKey: (key) => set({ artifactBusyKey: key }), + setArtifactErrors: (updater) => + set((s: any) => ({ artifactErrors: updater(s.artifactErrors) })), + setExpandedRunId: (id) => + set((s: any) => ({ + expandedRunId: + typeof id === "function" + ? (id as (current: string | null) => string | null)(s.expandedRunId) + : id, + })), + + // ---- mutations / loaders ---- + + refreshRuns: async (scheduleId, showLoading = false) => { + const api = requireApi(); + if (showLoading) set({ runsLoading: true }); + try { + const items = await api.listScheduleRuns({ scheduleId, limit: 20 }); + if (get().schedule?.schedule_id === scheduleId) { + set({ runs: items }); + } + setApiOnline(true); + } catch (error) { + if (showLoading) await handleError(get, error, "运行记录加载失败"); + } finally { + // 如果用户已切换到另一个调度方案,不能让旧请求结束时覆盖新方案的 + // 加载状态;新方案会由自己的请求负责关闭 loading。 + if (showLoading && get().schedule?.schedule_id === scheduleId) { + set({ runsLoading: false }); + } + } + }, + + toggleRun: (runId) => { + const expanded = get().expandedRunId; + if (expanded === runId) { + set({ expandedRunId: null }); + } else { + set({ expandedRunId: runId }); + } + }, + + downloadResult: async (runId, nodeRunId) => { + const api = requireApi(); + const busyKey = `${nodeRunId}:result`; + set({ + artifactBusyKey: busyKey, + artifactErrors: (() => { + const next = { ...get().artifactErrors }; + 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) { + set((s: any) => ({ + artifactErrors: { + ...s.artifactErrors, + [nodeRunId]: error instanceof Error ? error.message : "结果下载失败", + }, + })); + } finally { + set({ artifactBusyKey: null }); + } + }, + + downloadLog: async (runId, nodeRunId) => { + const api = requireApi(); + const busyKey = `${nodeRunId}:log-download`; + set({ + artifactBusyKey: busyKey, + artifactErrors: (() => { + const next = { ...get().artifactErrors }; + 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) { + set((s: any) => ({ + artifactErrors: { + ...s.artifactErrors, + [nodeRunId]: + error instanceof Error ? error.message : "日志下载失败", + }, + })); + } finally { + set({ artifactBusyKey: null }); + } + }, +}); \ No newline at end of file diff --git a/frontend/app/features/schedules/state/schedulesStore.ts b/frontend/app/features/schedules/state/schedulesStore.ts deleted file mode 100644 index e2a2f50..0000000 --- a/frontend/app/features/schedules/state/schedulesStore.ts +++ /dev/null @@ -1,1251 +0,0 @@ -import { create } from "zustand"; - -import { - ApiRequestError, - type CronPreview, - type Schedule, - type ScheduleArtifact, - type ScheduleEdge, - type ScheduleNode, - type ScheduleRunDetail, - type ScheduleRunSummary, - type WorkspaceBoundApi, -} from "../../../services/api"; - -import { useScriptWorkspaceStore } from "../../platform/state/scriptWorkspaceStore"; -import { useUiStore } from "../../platform/state/uiStore"; - -type Notice = { - tone: "success" | "error" | "info"; - message: string; -}; - -// ---- Types ---- - -export type ScheduleForm = { - scheduleName: string; - description: string; - triggerType: "manual" | "cron" | "api"; - cronExpression: string; - timezone: string; - enabled: boolean; - maxConcurrency: string; - failurePolicy: "stop" | "continue"; -}; - -export type PythonVersion = "3.8" | "3.10" | "3.12"; - -export const PYTHON_VERSION_OPTIONS: readonly PythonVersion[] = [ - "3.8", - "3.10", - "3.12", -]; - -export type NodeForm = { - nodeName: string; - timeoutSeconds: string; - retryCount: string; - retryIntervalSec: string; - argumentsJson: string; - envRefsJson: string; - pythonVersion: PythonVersion; -}; - -export type NodePositionDraft = { - position_x: number; - position_y: number; -}; - -export type ScheduleContextMenu = - | { x: number; y: number; kind: "schedule-list" } - | { x: number; y: number; kind: "schedule"; schedule: Schedule } - | { - x: number; - y: number; - kind: "artifact"; - artifact: ScheduleArtifact; - } - | { x: number; y: number; kind: "node"; node: ScheduleNode } - | { x: number; y: number; kind: "edge"; edge: ScheduleEdge }; - -export const EMPTY_SCHEDULE_FORM: ScheduleForm = { - scheduleName: "", - description: "", - triggerType: "manual", - cronExpression: "", - timezone: "Asia/Shanghai", - enabled: false, - maxConcurrency: "1", - failurePolicy: "stop", -}; - -export const EMPTY_NODE_FORM: NodeForm = { - nodeName: "", - timeoutSeconds: "600", - retryCount: "0", - retryIntervalSec: "5", - argumentsJson: "{}", - envRefsJson: "{}", - pythonVersion: "3.12", -}; - -// ---- Module-level non-reactive holders ---- - -let _api: WorkspaceBoundApi | null = null; -const positionDrafts = new Map(); - -export const bindSchedulesApi = (api: WorkspaceBoundApi | null) => { - _api = api; -}; - -function requireApi(): WorkspaceBoundApi { - if (!_api) throw new Error("schedules API 未绑定"); - return _api; -} - -function notify(notice: Notice) { - useUiStore.getState().pushToast(notice); -} - -function setApiOnline(online: boolean) { - useScriptWorkspaceStore.getState().setApiOnline(online); -} - -// ---- Helpers ---- - -function scheduleToForm(schedule: Schedule): ScheduleForm { - return { - scheduleName: schedule.schedule_name, - description: schedule.description ?? "", - triggerType: schedule.trigger_type, - cronExpression: schedule.cron_expression ?? "", - 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), - pythonVersion: (node.python_version ?? "3.12") as PythonVersion, - }; -} - -function parseObject(text: string, label: string): Record { - const trimmed = text.trim(); - if (!trimmed) return {}; - try { - const parsed = JSON.parse(trimmed); - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new Error(`${label} 必须是 JSON 对象`); - } - return parsed as Record; - } catch (error) { - if (error instanceof SyntaxError) { - throw new Error(`${label} JSON 格式不正确`); - } - throw error; - } -} - -function applyPositionDrafts(schedule: Schedule): Schedule { - if (positionDrafts.size === 0) return schedule; - return { - ...schedule, - nodes: schedule.nodes.map((node) => { - const draft = positionDrafts.get(node.node_id); - return draft ? { ...node, ...draft } : node; - }), - }; -} - -function clearPositionDrafts() { - positionDrafts.clear(); -} - -// ---- Store ---- - -type LogState = { - loading: boolean; - content: string | null; - fileName: string | null; - error: string | null; -}; - -type State = { - schedules: Schedule[]; - artifacts: ScheduleArtifact[]; - schedule: Schedule | null; - - loading: boolean; - busy: string | null; - - selectedNodeId: string | null; - selectedEdgeId: string | null; - linkSourceId: string | null; - - scheduleKeyword: string; - artifactKeyword: string; - - createDialogOpen: boolean; - newScheduleName: string; - - scheduleForm: ScheduleForm; - nodeForm: NodeForm; - cronResult: CronPreview | null; - contextMenu: ScheduleContextMenu | null; - - runs: ScheduleRunSummary[]; - runsLoading: boolean; - - expandedRunId: string | null; - runDetails: Record; - detailLoadingId: string | null; - detailErrors: Record; - openLogNodeRunIds: Set; - logStates: Record; - artifactBusyKey: string | null; - artifactErrors: Record; - positionDraftCount: number; -}; - -type Actions = { - bindApi: (api: WorkspaceBoundApi | null) => void; - reset: () => void; - - // pure setters (mostly used by canvas/drag/inspector) - 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; - setRuns: ( - value: ScheduleRunSummary[] - | ((current: ScheduleRunSummary[]) => ScheduleRunSummary[]), - ) => void; - setRunsLoading: (loading: boolean) => void; - setPositionDraftCount: (count: number) => void; - setRunDetails: ( - updater: (current: Record) => Record, - ) => void; - setOpenLogNodeRunIds: ( - value: Set | ((current: Set) => Set), - ) => void; - setLogStates: ( - updater: (current: Record) => Record, - ) => void; - setArtifactBusyKey: (key: string | null) => void; - setArtifactErrors: ( - updater: (current: Record) => Record, - ) => void; - setDetailLoadingId: (id: string | null) => void; - setDetailErrors: ( - updater: (current: Record) => Record, - ) => void; - setSelectedNodeId: (id: string | null) => void; - setSelectedEdgeId: (id: string | null) => void; - setLinkSourceId: (id: string | null) => void; - setScheduleKeyword: (k: string) => void; - setArtifactKeyword: (k: string) => void; - setCreateDialogOpen: (open: boolean) => void; - setNewScheduleName: (name: string) => void; - setScheduleForm: (form: ScheduleForm) => void; - setNodeForm: (form: NodeForm) => void; - setCronResult: (result: CronPreview | null) => void; - setContextMenu: (menu: ScheduleContextMenu | null) => void; - closeContextMenu: () => void; - - // data loaders - loadInitial: () => Promise; - refreshLists: (preferredScheduleId?: string | null) => Promise; - refreshRuns: (scheduleId: string, showLoading?: boolean) => Promise; - chooseSchedule: (scheduleId: string) => Promise; - - // mutations - openCreateDialog: () => void; - addSchedule: (name: string) => Promise; - removeSchedule: (target?: Schedule) => Promise; - renameSchedule: (target: Schedule) => Promise; - removeArtifact: (artifact: ScheduleArtifact) => Promise; - saveSchedule: () => Promise; - runCronPreview: () => Promise; - runNow: () => Promise; - addArtifactAt: ( - artifact: ScheduleArtifact, - positionX: number, - positionY: number, - ) => Promise; - saveNode: (selectedNode: ScheduleNode | null) => Promise; - removeNode: (target?: ScheduleNode) => Promise; - removeEdge: (target?: ScheduleEdge) => Promise; - checkDag: () => Promise; - connectTo: (targetNodeId: string) => Promise; - - // runs / log helpers - toggleRun: (runId: string) => void; - toggleLog: (runId: string, nodeRunId: string) => Promise; - downloadResult: (runId: string, nodeRunId: string) => Promise; - downloadLog: (runId: string, nodeRunId: string) => Promise; - setExpandedRunId: ( - id: string | null | ((current: string | null) => string | null), - ) => void; - - // drag helpers - setPositionDraft: (nodeId: string, draft: NodePositionDraft) => void; - getPositionDrafts: () => Map; - clearAllPositionDrafts: () => void; - prunePositionDrafts: (validNodeIds: Set) => void; -}; - -const initial: State = { - schedules: [], - artifacts: [], - schedule: null, - loading: true, - busy: null, - selectedNodeId: null, - selectedEdgeId: null, - linkSourceId: null, - scheduleKeyword: "", - artifactKeyword: "", - createDialogOpen: false, - newScheduleName: "", - scheduleForm: EMPTY_SCHEDULE_FORM, - nodeForm: EMPTY_NODE_FORM, - cronResult: null, - contextMenu: null, - runs: [], - runsLoading: false, - expandedRunId: null, - runDetails: {}, - detailLoadingId: null, - detailErrors: {}, - openLogNodeRunIds: new Set(), - logStates: {}, - artifactBusyKey: null, - artifactErrors: {}, - positionDraftCount: 0, -}; - -export const useSchedulesStore = create((set, get) => { - // Generic error handler with 412 (concurrent update) awareness - async function handleError(error: unknown, fallback: string): Promise { - if (error instanceof ApiRequestError && error.status === 412) { - const currentId = get().schedule?.schedule_id; - if (currentId) { - get().refreshLists(currentId).catch(() => undefined); - } - notify({ tone: "error", message: "调度已被其他操作更新,已重新加载最新版本" }); - return; - } - notify({ - tone: "error", - message: error instanceof Error ? error.message : fallback, - }); - } - - function applyServerUpdatedSchedule(serverUpdated: Schedule): void { - const validNodeIds = new Set( - serverUpdated.nodes.map((node) => node.node_id), - ); - for (const nodeId of [...positionDrafts.keys()]) { - if (!validNodeIds.has(nodeId)) positionDrafts.delete(nodeId); - } - const updated = applyPositionDrafts(serverUpdated); - set((state) => ({ - schedule: updated, - schedules: (() => { - const summary = { ...updated, nodes: [], edges: [] }; - const index = state.schedules.findIndex( - (item) => item.schedule_id === updated.schedule_id, - ); - if (index < 0) return [summary, ...state.schedules]; - return state.schedules.map((item) => - item.schedule_id === updated.schedule_id ? summary : item - ); - })(), - positionDraftCount: positionDrafts.size, - })); - } - - // Generic mutation wrapper: busy + try/catch + 412 + positionDraft cleanup + schedules list update - async function withMutation( - label: string, - action: () => Promise, - successMessage: string, - ): Promise { - const api = requireApi(); - if (get().busy) return null; - set({ busy: label }); - try { - const serverUpdated = await action(); - applyServerUpdatedSchedule(serverUpdated); - notify({ tone: "success", message: successMessage }); - return serverUpdated; - } catch (error) { - await handleError(error, `${successMessage}失败`); - return null; - } finally { - set({ busy: null }); - } - } - - return { - ...initial, - - bindApi: bindSchedulesApi, - - reset: () => { - set({ ...initial, loading: true }); - }, - - - // ---- pure setters ---- - - setSchedule: (value) => - set((state) => ({ - schedule: - typeof value === "function" - ? (value as (current: Schedule | null) => Schedule | null)(state.schedule) - : value, - })), - setSchedules: (value) => - set((state) => ({ - schedules: - typeof value === "function" - ? (value as (current: Schedule[]) => Schedule[])(state.schedules) - : value, - })), - setArtifacts: (value) => - set((state) => ({ - artifacts: - typeof value === "function" - ? (value as (current: ScheduleArtifact[]) => ScheduleArtifact[])( - state.artifacts, - ) - : value, - })), - setLoading: (loading) => set({ loading }), - setBusy: (busy) => set({ busy }), - setRuns: (value) => - set((state) => ({ - runs: - typeof value === "function" - ? (value as (current: ScheduleRunSummary[]) => ScheduleRunSummary[])( - state.runs, - ) - : value, - })), - setRunsLoading: (loading) => set({ runsLoading: loading }), - setRunDetails: (updater) => - set((state) => ({ runDetails: updater(state.runDetails) })), - setOpenLogNodeRunIds: (value) => - set((state) => ({ - openLogNodeRunIds: - typeof value === "function" - ? (value as (current: Set) => Set)( - state.openLogNodeRunIds, - ) - : value, - })), - setLogStates: (updater) => - set((state) => ({ logStates: updater(state.logStates) })), - setArtifactBusyKey: (key) => set({ artifactBusyKey: key }), - setArtifactErrors: (updater) => - set((state) => ({ artifactErrors: updater(state.artifactErrors) })), - setDetailLoadingId: (id) => set({ detailLoadingId: id }), - setDetailErrors: (updater) => - set((state) => ({ detailErrors: updater(state.detailErrors) })), - setSelectedNodeId: (id) => set({ selectedNodeId: id }), - setSelectedEdgeId: (id) => set({ selectedEdgeId: id }), - setLinkSourceId: (id) => set({ linkSourceId: id }), - setScheduleKeyword: (k) => set({ scheduleKeyword: k }), - setArtifactKeyword: (k) => set({ artifactKeyword: k }), - setCreateDialogOpen: (open) => set({ createDialogOpen: open }), - setNewScheduleName: (name) => set({ newScheduleName: name }), - setScheduleForm: (form) => set({ scheduleForm: form }), - setNodeForm: (form) => set({ nodeForm: form }), - setCronResult: (result) => set({ cronResult: result }), - setContextMenu: (menu) => set({ contextMenu: menu }), - closeContextMenu: () => set({ contextMenu: null }), - - // ---- 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) }); - }, - - refreshRuns: async (scheduleId, showLoading = false) => { - const api = requireApi(); - if (showLoading) set({ runsLoading: true }); - try { - const items = await api.listScheduleRuns({ scheduleId, limit: 20 }); - if (get().schedule?.schedule_id === scheduleId) { - set({ runs: items }); - } - setApiOnline(true); - } catch (error) { - if (showLoading) await handleError(error, "运行记录加载失败"); - } finally { - // 如果用户已切换到另一个调度方案,不能让旧请求结束时覆盖新方案的 - // 加载状态;新方案会由自己的请求负责关闭 loading。 - if (showLoading && get().schedule?.schedule_id === scheduleId) { - set({ runsLoading: false }); - } - } - }, - - 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) { - await handleError(error, "调度详情加载失败"); - } finally { - set({ busy: null }); - } - }, - - // ---- mutations ---- - - openCreateDialog: () => { - if (get().busy) return; - set((state) => ({ - contextMenu: null, - newScheduleName: `新建调度 ${state.schedules.length + 1}`, - createDialogOpen: true, - })); - }, - - 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((state) => ({ - schedules: [created, ...state.schedules], - schedule: created, - createDialogOpen: false, - newScheduleName: "", - })); - get().clearAllPositionDrafts(); - set({ selectedNodeId: null, selectedEdgeId: null }); - notify({ tone: "success", message: "调度方案已创建" }); - } catch (error) { - await handleError(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) { - await handleError(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((state) => ({ - schedules: state.schedules.map((item) => - item.schedule_id === updated.schedule_id - ? { ...updated, nodes: [], edges: [] } - : item - ), - schedule: - state.schedule?.schedule_id === updated.schedule_id - ? updated - : state.schedule, - })); - notify({ tone: "success", message: "调度方案已改名" }); - } catch (error) { - await handleError(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((state) => ({ - artifacts: state.artifacts.filter( - (item) => item.versions_id !== artifact.versions_id, - ), - })); - notify({ - tone: "success", - message: "已移出调度列表,稳定版本和历史记录保持不变", - }); - } catch (error) { - await handleError(error, "移出调度列表失败"); - } finally { - set({ busy: null }); - } - }, - - saveSchedule: async () => { - const api = requireApi(); - const state = get(); - const { schedule, scheduleForm } = state; - if (!schedule || state.busy) return; - const maxConcurrency = Number(scheduleForm.maxConcurrency); - if (!scheduleForm.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 { - 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: 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, - }); - get().clearAllPositionDrafts(); - set((s) => ({ - schedule: updated, - schedules: s.schedules.map((item) => - item.schedule_id === updated.schedule_id - ? { ...updated, nodes: [], edges: [] } - : item - ), - })); - - notify({ tone: "success", message: "调度配置已保存" }); - } catch (error) { - const localDraft = applyPositionDrafts(updated); - set({ schedule: localDraft }); - await handleError(error, "调度配置保存失败"); - } finally { - set({ busy: null }); - } - }, - - runCronPreview: async () => { - const api = requireApi(); - const { scheduleForm } = get(); - if (scheduleForm.triggerType !== "cron") return; - if (get().busy) return; - set({ busy: "cron-preview" }); - try { - const result = await api.previewCron({ - cron_expression: scheduleForm.cronExpression.trim(), - timezone: scheduleForm.timezone.trim(), - count: 5, - }); - set({ cronResult: result }); - notify({ tone: "success", message: "Cron 表达式校验通过" }); - } catch (error) { - set({ cronResult: null }); - await handleError(error, "Cron 预览失败"); - } 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) => ({ - runs: [ - created, - ...s.runs.filter((item) => 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) => - 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) { - await handleError(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( - "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 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("重试间隔必须是非负整数"); - } - if (!PYTHON_VERSION_OPTIONS.includes(nodeForm.pythonVersion)) { - throw new Error("Python 版本必须是 3.8 / 3.10 / 3.12"); - } - 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, - python_version: nodeForm.pythonVersion, - }, - ), - "节点配置已保存", - ); - } catch (error) { - await handleError(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(updated); - if (selectedNodeId === node.node_id) set({ selectedNodeId: null }); - notify({ tone: "success", message: "节点及其运行日志已删除" }); - } catch (error) { - await handleError(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( - "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((state) => ({ - schedule: state.schedule - ? { ...state.schedule, dag_validation: result } - : state.schedule, - })); - notify({ - tone: result.valid ? "success" : "error", - message: result.valid - ? "当前 DAG 通过校验" - : "当前 DAG 存在校验问题", - }); - } catch (error) { - await handleError(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( - "create-edge", - () => - requireApi().createScheduleEdge(schedule.schedule_id, { - workflow_version: schedule.workflow_version, - source_node_id: sourceId, - target_node_id: targetNodeId, - }), - "节点连线已创建", - ); - }, - - // ---- runs / logs ---- - - toggleRun: (runId) => { - const expanded = get().expandedRunId; - if (expanded === runId) { - set({ expandedRunId: null }); - } else { - set({ expandedRunId: runId }); - } - }, - setExpandedRunId: (id) => - set((state) => ({ - expandedRunId: - typeof id === "function" - ? (id as (current: string | null) => string | null)( - state.expandedRunId, - ) - : id, - })), - - toggleLog: async (runId, nodeRunId) => { - const api = requireApi(); - const state = get(); - const open = state.openLogNodeRunIds; - if (open.has(nodeRunId)) { - const next = new Set(open); - next.delete(nodeRunId); - set({ openLogNodeRunIds: next }); - return; - } - const next = new Set(open); - next.add(nodeRunId); - set({ openLogNodeRunIds: next }); - if (state.logStates[nodeRunId]?.content) return; - set((s) => ({ - logStates: { - ...s.logStates, - [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(); - set((s) => ({ - logStates: { - ...s.logStates, - [nodeRunId]: { - loading: false, - content, - fileName: artifacts.log?.file_name ?? null, - error: null, - }, - }, - })); - } catch (error) { - set((s) => ({ - logStates: { - ...s.logStates, - [nodeRunId]: { - loading: false, - content: null, - fileName: null, - error: error instanceof Error ? error.message : "日志读取失败", - }, - }, - })); - } - }, - - downloadResult: async (runId, nodeRunId) => { - const api = requireApi(); - const busyKey = `${nodeRunId}:result`; - set({ - artifactBusyKey: busyKey, - artifactErrors: (() => { - const next = { ...get().artifactErrors }; - 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) { - set((state) => ({ - artifactErrors: { - ...state.artifactErrors, - [nodeRunId]: error instanceof Error ? error.message : "结果下载失败", - }, - })); - } finally { - set({ artifactBusyKey: null }); - } - }, - - downloadLog: async (runId, nodeRunId) => { - const api = requireApi(); - const busyKey = `${nodeRunId}:log-download`; - set({ - artifactBusyKey: busyKey, - artifactErrors: (() => { - const next = { ...get().artifactErrors }; - 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) { - set((state) => ({ - artifactErrors: { - ...state.artifactErrors, - [nodeRunId]: - error instanceof Error ? error.message : "日志下载失败", - }, - })); - } finally { - set({ artifactBusyKey: null }); - } - }, - - // ---- drag helpers ---- - - setPositionDraft: (nodeId, draft) => { - positionDrafts.set(nodeId, draft); - set({ positionDraftCount: positionDrafts.size }); - }, - setPositionDraftCount: (count) => set({ positionDraftCount: count }), - getPositionDrafts: () => positionDrafts, - clearAllPositionDrafts: () => { - clearPositionDrafts(); - set({ positionDraftCount: 0 }); - }, - prunePositionDrafts: (validNodeIds) => { - for (const nodeId of [...positionDrafts.keys()]) { - if (!validNodeIds.has(nodeId)) positionDrafts.delete(nodeId); - } - set({ positionDraftCount: positionDrafts.size }); - }, - }; -}); - -// 顶层 pure helper (scheduleNodeKey needs schedule, so it's defined outside the store closure) -function artifactNodeKey(artifact: ScheduleArtifact, schedule: Schedule): string { - const ascii = artifact.script_name - .toLowerCase() - .replace(/[^a-z0-9_]+/g, "_") - .replace(/^_+|_+$/g, "") - .slice(0, 24); - 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; - for (let i = 2; i < 1000; i++) { - const candidate = `${base}_${i}`; - if (!existing.has(candidate)) return candidate; - } - return `${base}_${Date.now().toString(36)}`; -} diff --git a/frontend/app/features/schedules/state/types.ts b/frontend/app/features/schedules/state/types.ts new file mode 100644 index 0000000..28873c7 --- /dev/null +++ b/frontend/app/features/schedules/state/types.ts @@ -0,0 +1,92 @@ +// ---- Module types ---- + +import type { CronPreview } from "../../../services/api"; + +export type Notice = { + tone: "success" | "error" | "info"; + message: string; +}; + +export type ScheduleForm = { + scheduleName: string; + description: string; + triggerType: "manual" | "cron" | "api"; + cronExpression: string; + timezone: string; + enabled: boolean; + maxConcurrency: string; + failurePolicy: "stop" | "continue"; +}; + +export type PythonVersion = "3.8" | "3.10" | "3.12"; + +export const PYTHON_VERSION_OPTIONS: readonly PythonVersion[] = [ + "3.8", + "3.10", + "3.12", +]; + +export type NodeForm = { + nodeName: string; + timeoutSeconds: string; + retryCount: string; + retryIntervalSec: string; + argumentsJson: string; + envRefsJson: string; + pythonVersion: PythonVersion; +}; + +export type NodePositionDraft = { + position_x: number; + position_y: number; +}; + +export type LogState = { + loading: boolean; + content: string | null; + fileName: string | null; + error: string | null; +}; + +// ---- ScheduleContextMenu (re-declared here, but the canonical type is in store/types to avoid pulling store into components) ---- +import type { + Schedule, + ScheduleArtifact, + ScheduleEdge, + ScheduleNode, +} from "../../../services/api"; + +export type ScheduleContextMenu = + | { x: number; y: number; kind: "schedule-list" } + | { x: number; y: number; kind: "schedule"; schedule: Schedule } + | { + x: number; + y: number; + kind: "artifact"; + artifact: ScheduleArtifact; + } + | { x: number; y: number; kind: "node"; node: ScheduleNode } + | { x: number; y: number; kind: "edge"; edge: ScheduleEdge }; + +// ---- Form defaults ---- + +export const EMPTY_SCHEDULE_FORM: ScheduleForm = { + scheduleName: "", + description: "", + triggerType: "manual", + cronExpression: "", + timezone: "Asia/Shanghai", + enabled: false, + maxConcurrency: "1", + failurePolicy: "stop", +}; + +export const EMPTY_NODE_FORM: NodeForm = { + nodeName: "", + timeoutSeconds: "600", + retryCount: "0", + retryIntervalSec: "5", + argumentsJson: "{}", + envRefsJson: "{}", + pythonVersion: "3.12", +}; diff --git a/frontend/app/features/schedules/state/useSchedulesStore.ts b/frontend/app/features/schedules/state/useSchedulesStore.ts new file mode 100644 index 0000000..fcc9ff5 --- /dev/null +++ b/frontend/app/features/schedules/state/useSchedulesStore.ts @@ -0,0 +1,110 @@ +// Root store: composes 5 slices into the same-named `useSchedulesStore`. +// External consumers keep importing `{ useSchedulesStore }` from this file. +// +// The legacy monolithic file exported both `useSchedulesStore` and +// `bindSchedulesApi`; we re-export the binding helper from `./helpers` here +// so `routes/platform.tsx` (and any future callers) get the same surface. + +import { create } from "zustand"; + +import { bindSchedulesApi } from "./helpers"; +import { createCanvasSlice } from "./canvasSlice"; +import { createDialogSlice } from "./dialogSlice"; +import { createListSlice } from "./listSlice"; +import { createLogsSlice } from "./logsSlice"; +import { createRunsSlice } from "./runsSlice"; +import type { CanvasSliceActions, CanvasSliceState } from "./canvasSlice"; +import type { DialogSliceActions, DialogSliceState } from "./dialogSlice"; +import type { ListSliceActions, ListSliceState } from "./listSlice"; +import type { LogsSliceActions, LogsSliceState } from "./logsSlice"; +import type { RunsSliceActions, RunsSliceState } from "./runsSlice"; +import type { WorkspaceBoundApi } from "../../../services/api"; + +// ---- Initial values (used by reset() to mirror the legacy monolithic `initial` const) ---- + +import { EMPTY_NODE_FORM, EMPTY_SCHEDULE_FORM } from "./types"; + +// ---- Combined types (re-used by each slice's StateCreator generic) ---- + +export type SchedulesState = + & ListSliceState + & CanvasSliceState + & DialogSliceState + & RunsSliceState + & LogsSliceState; + +export type SchedulesActions = + & ListSliceActions + & CanvasSliceActions + & DialogSliceActions + & RunsSliceActions + & LogsSliceActions + & { + bindApi: (api: WorkspaceBoundApi | null) => void; + reset: () => void; + }; + +export type SchedulesStore = SchedulesState & SchedulesActions; + +const INITIAL: Omit = { + // List + schedules: [], + artifacts: [], + loading: true, + // Canvas + selectedNodeId: null, + selectedEdgeId: null, + linkSourceId: null, + scheduleKeyword: "", + artifactKeyword: "", + scheduleForm: EMPTY_SCHEDULE_FORM, + nodeForm: EMPTY_NODE_FORM, + contextMenu: null, + positionDraftCount: 0, + // Dialog + createDialogOpen: false, + newScheduleName: "", + cronResult: null, + // Runs + runs: [], + runsLoading: false, + expandedRunId: null, + runDetails: {}, + detailLoadingId: null, + detailErrors: {}, + artifactBusyKey: null, + artifactErrors: {}, + // Logs + openLogNodeRunIds: new Set(), + logStates: {}, +}; + +export const useSchedulesStore = create()( + (set, get, store) => ({ + ...createListSlice(set, get, store), + ...createCanvasSlice(set, get, store), + ...createDialogSlice(set, get, store), + ...createRunsSlice(set, get, store), + ...createLogsSlice(set, get, store), + + // ---- global actions (cross-slice) ---- + + bindApi: (api) => { + bindSchedulesApi(api); + }, + + reset: () => { + // Mirror legacy `set({ ...initial, loading: true })` — wipe every + // slice field back to its default. schedule/busy are explicitly set + // because their default values differ from the Omit above. + set({ + ...INITIAL, + schedule: null, + busy: null, + loading: true, + }); + }, + }), +); + +export { bindSchedulesApi }; \ No newline at end of file diff --git a/frontend/app/features/schedules/utils.ts b/frontend/app/features/schedules/utils.ts index 7a2822b..52e9ab3 100644 --- a/frontend/app/features/schedules/utils.ts +++ b/frontend/app/features/schedules/utils.ts @@ -6,7 +6,7 @@ import { type ScheduleRunSummary, } from "../../services/api"; import { EMPTY_SCHEDULE_FORM, EMPTY_NODE_FORM } from "./constants"; -import type { NodeForm } from "./state/schedulesStore"; +import type { NodeForm } from "./state/types"; export function formatTime(value: string | null): string { if (!value) return "尚未执行"; diff --git a/frontend/app/routes/platform.tsx b/frontend/app/routes/platform.tsx index 71e09d0..b2289a9 100644 --- a/frontend/app/routes/platform.tsx +++ b/frontend/app/routes/platform.tsx @@ -16,7 +16,7 @@ import { } from "../features/platform/state/scriptWorkspaceStore"; import { useUiStore } from "../features/platform/state/uiStore"; import { bindAdminApi } from "../features/admin/state/adminStore"; -import { bindSchedulesApi } from "../features/schedules/state/schedulesStore"; +import { bindSchedulesApi } from "../features/schedules/state/useSchedulesStore"; import "../styles/platform.css";