204 lines
6.5 KiB
TypeScript
204 lines
6.5 KiB
TypeScript
// 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 });
|
||
}
|
||
},
|
||
}); |