refactor: useSchedulesStore.ts
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import { useSchedulesStore } from "../state/schedulesStore";
|
||||
import { useSchedulesStore } from "../state/useSchedulesStore";
|
||||
|
||||
/**
|
||||
* 监听全局 pointerdown / blur / resize / scroll / Escape,关闭当前打开的右键菜单。
|
||||
|
||||
@@ -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<string, NodePositionDraft>;
|
||||
clearAllPositionDrafts: () => void;
|
||||
prunePositionDrafts: (validNodeIds: Set<string>) => 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<SchedulesStore, [], [], CanvasSliceState & CanvasSliceActions> = (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 });
|
||||
},
|
||||
});
|
||||
@@ -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<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 createDialogSlice: StateCreator<SchedulesStore, [], [], DialogSliceState & DialogSliceActions> = (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 });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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<string, NodePositionDraft>();
|
||||
|
||||
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<string, unknown> {
|
||||
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<string, unknown>;
|
||||
} 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<void> {
|
||||
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<SchedulesStore> | ((s: SchedulesStore) => Partial<SchedulesStore>)) => 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<SchedulesStore> | ((s: SchedulesStore) => Partial<SchedulesStore>)) => void,
|
||||
get: () => SchedulesStore,
|
||||
label: string,
|
||||
action: () => Promise<Schedule>,
|
||||
successMessage: string,
|
||||
): Promise<Schedule | null> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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<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;
|
||||
@@ -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<string>;
|
||||
logStates: Record<string, LogState>;
|
||||
};
|
||||
|
||||
// ---- Actions ----
|
||||
|
||||
export type LogsSliceActions = {
|
||||
// pure setters
|
||||
setOpenLogNodeRunIds: (
|
||||
value: Set<string> | ((current: Set<string>) => Set<string>),
|
||||
) => void;
|
||||
setLogStates: (
|
||||
updater: (current: Record<string, LogState>) => Record<string, LogState>,
|
||||
) => void;
|
||||
|
||||
// mutations
|
||||
toggleLog: (runId: string, nodeRunId: 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 createLogsSlice: StateCreator<SchedulesStore, [], [], LogsSliceState & LogsSliceActions> = (set, get) => ({
|
||||
// ---- initial state ----
|
||||
openLogNodeRunIds: new Set(),
|
||||
logStates: {},
|
||||
|
||||
// ---- pure setters ----
|
||||
|
||||
setOpenLogNodeRunIds: (value) =>
|
||||
set((s: any) => ({
|
||||
openLogNodeRunIds:
|
||||
typeof value === "function"
|
||||
? (value as (current: Set<string>) => Set<string>)(
|
||||
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 : "日志读取失败",
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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<string, ScheduleRunDetail>;
|
||||
detailLoadingId: string | null;
|
||||
detailErrors: Record<string, string>;
|
||||
|
||||
artifactBusyKey: string | null;
|
||||
artifactErrors: Record<string, string>;
|
||||
};
|
||||
|
||||
// ---- Actions ----
|
||||
|
||||
export type RunsSliceActions = {
|
||||
// pure setters
|
||||
setRuns: (
|
||||
value: ScheduleRunSummary[]
|
||||
| ((current: ScheduleRunSummary[]) => ScheduleRunSummary[]),
|
||||
) => void;
|
||||
setRunsLoading: (loading: boolean) => void;
|
||||
setRunDetails: (
|
||||
updater: (current: Record<string, ScheduleRunDetail>) => Record<string, ScheduleRunDetail>,
|
||||
) => void;
|
||||
setDetailLoadingId: (id: string | null) => void;
|
||||
setDetailErrors: (
|
||||
updater: (current: Record<string, string>) => Record<string, string>,
|
||||
) => void;
|
||||
setArtifactBusyKey: (key: string | null) => void;
|
||||
setArtifactErrors: (
|
||||
updater: (current: Record<string, string>) => Record<string, string>,
|
||||
) => void;
|
||||
setExpandedRunId: (
|
||||
id: string | null | ((current: string | null) => string | null),
|
||||
) => void;
|
||||
|
||||
// mutations / loaders
|
||||
refreshRuns: (scheduleId: string, showLoading?: boolean) => Promise<void>;
|
||||
toggleRun: (runId: string) => void;
|
||||
downloadResult: (runId: string, nodeRunId: string) => Promise<void>;
|
||||
downloadLog: (runId: string, nodeRunId: 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 createRunsSlice: StateCreator<SchedulesStore, [], [], RunsSliceState & RunsSliceActions> = (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 });
|
||||
}
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
};
|
||||
@@ -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<SchedulesState, "schedule" | "busy"> = {
|
||||
// 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<SchedulesStore>()(
|
||||
(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 };
|
||||
@@ -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 "尚未执行";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user