@goplusvn/core 0.1.53 → 0.1.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/features/README.md +5 -1
- package/features/system-jobs/README.md +40 -0
- package/features/system-jobs/migrations/0001_init.sql +47 -0
- package/features/system-jobs/schema.prisma +42 -0
- package/package.json +3 -1
- package/src/cron/__tests__/db-cron-manager.test.ts +316 -0
- package/src/cron/db-cron-manager.ts +459 -0
- package/src/cron/index.ts +24 -0
- package/src/system/pages/__tests__/system-jobs-page.test.tsx +92 -0
- package/src/system/pages/system-jobs-page.tsx +571 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import { SimpleCronJob } from "../infrastructure/cron/cron-manager";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
CronJob,
|
|
5
|
+
CronJobManager,
|
|
6
|
+
CronJobOptions,
|
|
7
|
+
} from "../infrastructure/cron/types";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Cron CÓ TRẠNG THÁI TRONG DB — engine dùng chung mọi app goerp (bảng
|
|
11
|
+
* `system_jobs` + `job_execution_logs` ship qua `goerp-features sync`, feature
|
|
12
|
+
* system-jobs). Bản `infrastructure/cron` chỉ hẹn giờ trong RAM: restart là
|
|
13
|
+
* mất bật/tắt, không có lịch sử chạy, admin không nhìn thấy gì.
|
|
14
|
+
*
|
|
15
|
+
* Vai trò từng lớp:
|
|
16
|
+
* - `SimpleCronJob` (infrastructure): bấm giờ setInterval thuần.
|
|
17
|
+
* - lớp này: DB là NGUỒN SỰ THẬT của cờ enabled + cronTime, bọc mỗi lần chạy
|
|
18
|
+
* thành 1 row `job_execution_logs` (running → success|failed, kèm actions
|
|
19
|
+
* do job tự ghi qua `getJobExecutionContext().log()`).
|
|
20
|
+
*
|
|
21
|
+
* App cắm qua `configureCronManager({ db, logger? })` (cùng khuôn
|
|
22
|
+
* configureTaskRunner/configureNotificationService), rồi đăng ký job trong
|
|
23
|
+
* `instrumentation.ts`.
|
|
24
|
+
*
|
|
25
|
+
* BỀN VỚI APP CHƯA SYNC FEATURE: thiếu model systemJob/jobExecutionLog thì
|
|
26
|
+
* chạy chế độ memory-only thay vì nổ — cron là hạ tầng nền, không được làm
|
|
27
|
+
* chết boot.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export interface ActionLog {
|
|
31
|
+
time: string;
|
|
32
|
+
action: string;
|
|
33
|
+
details?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Ngữ cảnh của LẦN CHẠY hiện tại — job dùng để ghi diễn biến vào log. */
|
|
37
|
+
export interface JobExecutionContext {
|
|
38
|
+
logId: string;
|
|
39
|
+
jobName: string;
|
|
40
|
+
startedAt: Date;
|
|
41
|
+
actions: ActionLog[];
|
|
42
|
+
log: (action: string, details?: string) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Delegate Prisma tối thiểu — structural, args `any` CÓ CHỦ ĐÍCH (client
|
|
47
|
+
* Prisma sinh ra hẹp hơn structural type — bài học SettingsDb/TaskDb).
|
|
48
|
+
* Cả 2 model đều optional: app chưa `goerp-features sync` vẫn chạy được.
|
|
49
|
+
*/
|
|
50
|
+
export interface CronDb {
|
|
51
|
+
systemJob?: {
|
|
52
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
53
|
+
count(args?: any): Promise<number>;
|
|
54
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
55
|
+
findUnique(args: any): Promise<any>;
|
|
56
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
57
|
+
create(args: any): Promise<any>;
|
|
58
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
59
|
+
update(args: any): Promise<any>;
|
|
60
|
+
};
|
|
61
|
+
jobExecutionLog?: {
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
63
|
+
create(args: any): Promise<any>;
|
|
64
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
65
|
+
update(args: any): Promise<any>;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface CronLogger {
|
|
70
|
+
info(message: string, meta?: unknown): void;
|
|
71
|
+
warn(message: string, meta?: unknown): void;
|
|
72
|
+
error(message: string, meta?: unknown): void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface CronConfig {
|
|
76
|
+
db: CronDb;
|
|
77
|
+
/** Bỏ trống thì dùng console (prefix [cron]). */
|
|
78
|
+
logger?: CronLogger;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const consoleLogger: CronLogger = {
|
|
82
|
+
info: (m, meta) => console.info(`[cron] ${m}`, meta ?? ""),
|
|
83
|
+
warn: (m, meta) => console.warn(`[cron] ${m}`, meta ?? ""),
|
|
84
|
+
error: (m, meta) => console.error(`[cron] ${m}`, meta ?? ""),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
let config: CronConfig | null = null;
|
|
88
|
+
|
|
89
|
+
export function configureCronManager(next: CronConfig): void {
|
|
90
|
+
config = next;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function requireConfig(): CronConfig {
|
|
94
|
+
if (!config) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
"[cron] chưa configureCronManager({ db, logger? }) — gọi 1 lần lúc khởi tạo app (instrumentation).",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return config;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function log(): CronLogger {
|
|
103
|
+
return config?.logger ?? consoleLogger;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let currentExecutionContext: JobExecutionContext | null = null;
|
|
107
|
+
|
|
108
|
+
/** Job đang chạy gọi để ghi diễn biến vào `job_execution_logs.actions`. */
|
|
109
|
+
export function getJobExecutionContext(): JobExecutionContext | null {
|
|
110
|
+
return currentExecutionContext;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class DbCronJobManager implements CronJobManager {
|
|
114
|
+
private jobs = new Map<string, CronJob>();
|
|
115
|
+
/** onTick GỐC của app — giữ để updateJob dựng lại job với cronTime mới. */
|
|
116
|
+
private jobCallbacks = new Map<string, () => void | Promise<void>>();
|
|
117
|
+
/** onTick ĐÃ BỌC (ghi log) — "chạy ngay" phải dùng bản này để có lịch sử. */
|
|
118
|
+
private wrappedCallbacks = new Map<string, () => void | Promise<void>>();
|
|
119
|
+
private isInitialized = false;
|
|
120
|
+
|
|
121
|
+
async init(): Promise<void> {
|
|
122
|
+
if (this.isInitialized) return;
|
|
123
|
+
try {
|
|
124
|
+
const { db } = requireConfig();
|
|
125
|
+
if (db.systemJob) {
|
|
126
|
+
const jobsCount = await db.systemJob.count();
|
|
127
|
+
log().info("DbCronJobManager initialized", { jobsCount });
|
|
128
|
+
} else {
|
|
129
|
+
log().warn(
|
|
130
|
+
"Thiếu model SystemJob (app chưa goerp-features sync system-jobs) — cron chạy memory-only",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
} catch (error) {
|
|
134
|
+
log().error("Khởi tạo DbCronJobManager lỗi", error);
|
|
135
|
+
} finally {
|
|
136
|
+
// Đánh dấu đã init kể cả khi lỗi — tránh vòng lặp thử lại mỗi request.
|
|
137
|
+
this.isInitialized = true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Bọc onTick: mở row execution log → chạy → đóng row (success/failed) và
|
|
143
|
+
* cập nhật trạng thái job. Dùng CHUNG cho addJob/updateJob/chạy-tay để
|
|
144
|
+
* mọi đường chạy đều để lại lịch sử.
|
|
145
|
+
*/
|
|
146
|
+
private wrapOnTick(
|
|
147
|
+
name: string,
|
|
148
|
+
onTick: () => void | Promise<void>,
|
|
149
|
+
): () => Promise<void> {
|
|
150
|
+
return async () => {
|
|
151
|
+
const { db } = requireConfig();
|
|
152
|
+
const startedAt = new Date();
|
|
153
|
+
let logId: string | null = null;
|
|
154
|
+
|
|
155
|
+
if (db.jobExecutionLog) {
|
|
156
|
+
try {
|
|
157
|
+
const entry = await db.jobExecutionLog.create({
|
|
158
|
+
data: { jobName: name, startedAt, status: "running", actions: [] },
|
|
159
|
+
});
|
|
160
|
+
logId = entry.id;
|
|
161
|
+
} catch (e) {
|
|
162
|
+
log().error(`Tạo execution log cho ${name} lỗi`, e);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const actions: ActionLog[] = [];
|
|
167
|
+
currentExecutionContext = {
|
|
168
|
+
logId: logId ?? "",
|
|
169
|
+
jobName: name,
|
|
170
|
+
startedAt,
|
|
171
|
+
actions,
|
|
172
|
+
log: (action, details) =>
|
|
173
|
+
actions.push({ time: new Date().toISOString(), action, details }),
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
await this.recordJobRun(name, "running");
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
await onTick();
|
|
180
|
+
const finishedAt = new Date();
|
|
181
|
+
await this.closeExecutionLog(logId, name, {
|
|
182
|
+
finishedAt,
|
|
183
|
+
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
|
184
|
+
status: "success",
|
|
185
|
+
actions,
|
|
186
|
+
});
|
|
187
|
+
await this.recordJobRun(name, "idle", finishedAt);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
const finishedAt = new Date();
|
|
190
|
+
await this.closeExecutionLog(logId, name, {
|
|
191
|
+
finishedAt,
|
|
192
|
+
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
|
193
|
+
status: "failed",
|
|
194
|
+
actions,
|
|
195
|
+
error: String(error),
|
|
196
|
+
});
|
|
197
|
+
await this.recordJobRun(name, "failed", undefined, String(error));
|
|
198
|
+
throw error;
|
|
199
|
+
} finally {
|
|
200
|
+
currentExecutionContext = null;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private async closeExecutionLog(
|
|
206
|
+
logId: string | null,
|
|
207
|
+
name: string,
|
|
208
|
+
result: {
|
|
209
|
+
finishedAt: Date;
|
|
210
|
+
durationMs: number;
|
|
211
|
+
status: "success" | "failed";
|
|
212
|
+
actions: ActionLog[];
|
|
213
|
+
error?: string;
|
|
214
|
+
},
|
|
215
|
+
): Promise<void> {
|
|
216
|
+
const { db } = requireConfig();
|
|
217
|
+
if (!logId || !db.jobExecutionLog) return;
|
|
218
|
+
try {
|
|
219
|
+
await db.jobExecutionLog.update({
|
|
220
|
+
where: { id: logId },
|
|
221
|
+
data: {
|
|
222
|
+
finishedAt: result.finishedAt,
|
|
223
|
+
durationMs: result.durationMs,
|
|
224
|
+
status: result.status,
|
|
225
|
+
actions: result.actions,
|
|
226
|
+
error: result.error,
|
|
227
|
+
summary:
|
|
228
|
+
result.status === "success"
|
|
229
|
+
? `Completed in ${result.durationMs}ms with ${result.actions.length} actions`
|
|
230
|
+
: `Failed after ${result.durationMs}ms: ${result.error}`,
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
} catch (e) {
|
|
234
|
+
log().error(`Cập nhật execution log cho ${name} lỗi`, e);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
addJob(options: CronJobOptions): CronJob {
|
|
239
|
+
this.jobCallbacks.set(options.name, options.onTick);
|
|
240
|
+
|
|
241
|
+
const wrappedOnTick = this.wrapOnTick(options.name, options.onTick);
|
|
242
|
+
const wrappedOptions: CronJobOptions = {
|
|
243
|
+
...options,
|
|
244
|
+
onTick: wrappedOnTick,
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const job = new SimpleCronJob(wrappedOptions);
|
|
248
|
+
this.jobs.set(options.name, job);
|
|
249
|
+
this.wrappedCallbacks.set(options.name, wrappedOnTick);
|
|
250
|
+
|
|
251
|
+
// Đồng bộ DB bất đồng bộ: DB quyết định job có chạy hay không.
|
|
252
|
+
void this.syncJobWithDb(wrappedOptions, job).catch((e) =>
|
|
253
|
+
log().error(`Đồng bộ job ${options.name} lỗi`, e),
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
return job;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private async syncJobWithDb(
|
|
260
|
+
options: CronJobOptions,
|
|
261
|
+
job: CronJob,
|
|
262
|
+
/** Ý chí ADMIN (updateJob truyền vào) — ghi đè cờ enabled đang có trong DB. */
|
|
263
|
+
enabledOverride?: boolean,
|
|
264
|
+
): Promise<void> {
|
|
265
|
+
const { db } = requireConfig();
|
|
266
|
+
if (!db.systemJob) {
|
|
267
|
+
if ((enabledOverride ?? options.start ?? true) !== false) job.start();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
let dbJob = await db.systemJob.findUnique({
|
|
273
|
+
where: { name: options.name },
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
if (!dbJob) {
|
|
277
|
+
dbJob = await db.systemJob.create({
|
|
278
|
+
data: {
|
|
279
|
+
name: options.name,
|
|
280
|
+
cronTime: options.cronTime,
|
|
281
|
+
enabled: enabledOverride ?? options.start ?? true,
|
|
282
|
+
status: "idle",
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
} else if (
|
|
286
|
+
enabledOverride !== undefined &&
|
|
287
|
+
dbJob.enabled !== enabledOverride
|
|
288
|
+
) {
|
|
289
|
+
// Không ghi xuống DB thì vòng dưới sẽ đọc lại cờ cũ và bật lại job.
|
|
290
|
+
await db.systemJob.update({
|
|
291
|
+
where: { name: options.name },
|
|
292
|
+
data: { enabled: enabledOverride },
|
|
293
|
+
});
|
|
294
|
+
dbJob.enabled = enabledOverride;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// DB là nguồn sự thật: admin tắt job thì deploy mới không tự bật lại.
|
|
298
|
+
if (dbJob.enabled && !job.isRunning()) job.start();
|
|
299
|
+
else if (!dbJob.enabled && job.isRunning()) job.stop();
|
|
300
|
+
|
|
301
|
+
// Lịch trong CODE thắng khi lập trình viên đổi — ghi ngược lại DB.
|
|
302
|
+
if (dbJob.cronTime !== options.cronTime) {
|
|
303
|
+
await db.systemJob.update({
|
|
304
|
+
where: { name: options.name },
|
|
305
|
+
data: { cronTime: options.cronTime },
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
} catch (e) {
|
|
309
|
+
log().error(`Đồng bộ job ${options.name} với DB lỗi`, e);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private async recordJobRun(
|
|
314
|
+
name: string,
|
|
315
|
+
status: string,
|
|
316
|
+
lastRun?: Date,
|
|
317
|
+
error?: string,
|
|
318
|
+
): Promise<void> {
|
|
319
|
+
const { db } = requireConfig();
|
|
320
|
+
if (!db.systemJob) return;
|
|
321
|
+
try {
|
|
322
|
+
await db.systemJob.update({
|
|
323
|
+
where: { name },
|
|
324
|
+
data: {
|
|
325
|
+
status,
|
|
326
|
+
lastRun,
|
|
327
|
+
error: error ?? null, // chạy được thì xoá lỗi cũ
|
|
328
|
+
nextRun: this.getJob(name)?.nextDate(),
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
} catch (e) {
|
|
332
|
+
log().error(`Cập nhật trạng thái job ${name} lỗi`, e);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
removeJob(name: string): void {
|
|
337
|
+
const job = this.jobs.get(name);
|
|
338
|
+
if (!job) return;
|
|
339
|
+
job.stop();
|
|
340
|
+
this.jobs.delete(name);
|
|
341
|
+
this.jobCallbacks.delete(name);
|
|
342
|
+
this.wrappedCallbacks.delete(name);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
getJob(name: string): CronJob | undefined {
|
|
346
|
+
return this.jobs.get(name);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
getAllJobs(): CronJob[] {
|
|
350
|
+
return Array.from(this.jobs.values());
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
startAll(): void {
|
|
354
|
+
this.jobs.forEach((job) => {
|
|
355
|
+
if (!job.isRunning()) job.start();
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
stopAll(): void {
|
|
360
|
+
this.jobs.forEach((job) => job.stop());
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Trạng thái runtime cho trang admin. */
|
|
364
|
+
getJobsInfo(): Array<{
|
|
365
|
+
name: string;
|
|
366
|
+
cronTime: string;
|
|
367
|
+
isRunning: boolean;
|
|
368
|
+
nextDate: Date | null;
|
|
369
|
+
}> {
|
|
370
|
+
return Array.from(this.jobs.values()).map((job) => ({
|
|
371
|
+
name: job.name,
|
|
372
|
+
cronTime: job.cronTime,
|
|
373
|
+
isRunning: job.isRunning(),
|
|
374
|
+
nextDate: job.nextDate(),
|
|
375
|
+
}));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async toggleJob(name: string): Promise<boolean> {
|
|
379
|
+
const job = this.jobs.get(name);
|
|
380
|
+
if (!job) return false;
|
|
381
|
+
|
|
382
|
+
const newState = !job.isRunning();
|
|
383
|
+
if (newState) job.start();
|
|
384
|
+
else job.stop();
|
|
385
|
+
|
|
386
|
+
const { db } = requireConfig();
|
|
387
|
+
if (db.systemJob) {
|
|
388
|
+
try {
|
|
389
|
+
await db.systemJob.update({
|
|
390
|
+
where: { name },
|
|
391
|
+
data: { enabled: newState },
|
|
392
|
+
});
|
|
393
|
+
} catch (e) {
|
|
394
|
+
log().error(`Ghi cờ enabled của job ${name} lỗi`, e);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Chạy tay (fire-and-forget) — vẫn đi qua bản BỌC nên có execution log. */
|
|
401
|
+
async runJobNow(name: string): Promise<boolean> {
|
|
402
|
+
const callback = this.wrappedCallbacks.get(name);
|
|
403
|
+
if (!callback) return false;
|
|
404
|
+
void Promise.resolve(callback()).catch((e) =>
|
|
405
|
+
log().error(`Chạy tay job ${name} lỗi`, e),
|
|
406
|
+
);
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Đổi lịch: dựng lại job từ onTick GỐC, giữ nguyên đường ghi log. */
|
|
411
|
+
async updateJob(
|
|
412
|
+
name: string,
|
|
413
|
+
newCronTime: string,
|
|
414
|
+
enabled?: boolean,
|
|
415
|
+
): Promise<boolean> {
|
|
416
|
+
const existingJob = this.jobs.get(name);
|
|
417
|
+
const originalOnTick = this.jobCallbacks.get(name);
|
|
418
|
+
if (!existingJob || !originalOnTick) {
|
|
419
|
+
log().warn(`Job ${name} không tồn tại — bỏ qua updateJob`);
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const wasRunning = existingJob.isRunning();
|
|
424
|
+
existingJob.stop();
|
|
425
|
+
this.jobs.delete(name);
|
|
426
|
+
|
|
427
|
+
const { db } = requireConfig();
|
|
428
|
+
let finalEnabled = enabled;
|
|
429
|
+
if (finalEnabled === undefined && db.systemJob) {
|
|
430
|
+
try {
|
|
431
|
+
const dbJob = await db.systemJob.findUnique({ where: { name } });
|
|
432
|
+
finalEnabled = dbJob?.enabled ?? wasRunning;
|
|
433
|
+
} catch (e) {
|
|
434
|
+
log().error(`Đọc trạng thái job ${name} từ DB lỗi`, e);
|
|
435
|
+
finalEnabled = wasRunning;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const wrappedOnTick = this.wrapOnTick(name, originalOnTick);
|
|
440
|
+
const newJobOptions: CronJobOptions = {
|
|
441
|
+
name,
|
|
442
|
+
cronTime: newCronTime,
|
|
443
|
+
onTick: wrappedOnTick,
|
|
444
|
+
start: finalEnabled ?? false,
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
const newJob = new SimpleCronJob(newJobOptions);
|
|
448
|
+
this.jobs.set(name, newJob);
|
|
449
|
+
this.jobCallbacks.set(name, originalOnTick);
|
|
450
|
+
this.wrappedCallbacks.set(name, wrappedOnTick);
|
|
451
|
+
|
|
452
|
+
await this.syncJobWithDb(newJobOptions, newJob, enabled);
|
|
453
|
+
log().info(`Đổi lịch job ${name} → ${newCronTime}`);
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Singleton dùng chung app (đăng ký job ở instrumentation). */
|
|
459
|
+
export const cronManager = new DbCronJobManager();
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export {
|
|
2
|
+
configureCronManager,
|
|
3
|
+
cronManager,
|
|
4
|
+
DbCronJobManager,
|
|
5
|
+
getJobExecutionContext,
|
|
6
|
+
} from "./db-cron-manager";
|
|
7
|
+
export type {
|
|
8
|
+
ActionLog,
|
|
9
|
+
CronDb,
|
|
10
|
+
CronLogger,
|
|
11
|
+
JobExecutionContext,
|
|
12
|
+
} from "./db-cron-manager";
|
|
13
|
+
|
|
14
|
+
// Lớp hẹn giờ thuần RAM — dùng lại khi app không cần trạng thái DB.
|
|
15
|
+
export {
|
|
16
|
+
cronJobManager,
|
|
17
|
+
CronJobManagerImpl,
|
|
18
|
+
SimpleCronJob,
|
|
19
|
+
} from "../infrastructure/cron/cron-manager";
|
|
20
|
+
export type {
|
|
21
|
+
CronJob,
|
|
22
|
+
CronJobManager,
|
|
23
|
+
CronJobOptions,
|
|
24
|
+
} from "../infrastructure/cron/types";
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke render trang "Tác vụ định kỳ": mount thật (jsdom) với fetch giả — bắt
|
|
3
|
+
* lỗi import/hook trước khi app tiêu thụ. Trang này chỉ mở được sau đăng nhập
|
|
4
|
+
* (quyền admin) nên không kiểm được bằng cách gọi HTTP ẩn danh.
|
|
5
|
+
*/
|
|
6
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
7
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
|
|
9
|
+
import { SystemJobsPage } from "../system-jobs-page";
|
|
10
|
+
|
|
11
|
+
const JOB = {
|
|
12
|
+
name: "zns-dispatch",
|
|
13
|
+
cronTime: "* * * * *",
|
|
14
|
+
isRunning: true,
|
|
15
|
+
nextDate: null,
|
|
16
|
+
enabled: true,
|
|
17
|
+
status: "idle" as const,
|
|
18
|
+
lastRun: "2026-08-01T06:00:00.000Z",
|
|
19
|
+
nextRun: "2026-08-01T06:01:00.000Z",
|
|
20
|
+
error: null,
|
|
21
|
+
inMemory: true,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const LOG = {
|
|
25
|
+
id: "log1",
|
|
26
|
+
jobName: "zns-dispatch",
|
|
27
|
+
startedAt: "2026-08-01T06:00:00.000Z",
|
|
28
|
+
finishedAt: "2026-08-01T06:00:02.000Z",
|
|
29
|
+
durationMs: 2000,
|
|
30
|
+
status: "success" as const,
|
|
31
|
+
error: null,
|
|
32
|
+
summary: null,
|
|
33
|
+
actions: [{ time: "2026-08-01T06:00:01.000Z", action: "Gửi 3 tin ZNS" }],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function mockFetch(jobs = [JOB]) {
|
|
37
|
+
const calls: string[] = [];
|
|
38
|
+
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
|
39
|
+
const url = String(input);
|
|
40
|
+
calls.push(url);
|
|
41
|
+
const body = url.includes("/history")
|
|
42
|
+
? { data: [LOG], meta: { total: 1 } }
|
|
43
|
+
: { data: jobs };
|
|
44
|
+
return new Response(JSON.stringify(body), {
|
|
45
|
+
status: 200,
|
|
46
|
+
headers: { "Content-Type": "application/json" },
|
|
47
|
+
});
|
|
48
|
+
}) as unknown as typeof fetch;
|
|
49
|
+
return calls;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("SystemJobsPage", () => {
|
|
53
|
+
afterEach(() => vi.restoreAllMocks());
|
|
54
|
+
|
|
55
|
+
it("render job từ API kèm lịch chạy", async () => {
|
|
56
|
+
mockFetch();
|
|
57
|
+
render(<SystemJobsPage />);
|
|
58
|
+
|
|
59
|
+
await waitFor(() => expect(screen.getByText("zns-dispatch")).toBeDefined());
|
|
60
|
+
expect(screen.getByText("* * * * *")).toBeDefined();
|
|
61
|
+
expect(screen.getByText("Chờ")).toBeDefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("bung dòng → gọi endpoint lịch sử và hiện actions của lần chạy", async () => {
|
|
65
|
+
const calls = mockFetch();
|
|
66
|
+
render(<SystemJobsPage apiUrl="/api/jobs" />);
|
|
67
|
+
|
|
68
|
+
await waitFor(() => expect(screen.getByText("zns-dispatch")).toBeDefined());
|
|
69
|
+
fireEvent.click(screen.getByText("zns-dispatch"));
|
|
70
|
+
|
|
71
|
+
await waitFor(() => expect(screen.getByText("Thành công")).toBeDefined());
|
|
72
|
+
expect(
|
|
73
|
+
calls.some((u) => u.startsWith("/api/jobs/zns-dispatch/history?")),
|
|
74
|
+
).toBe(true);
|
|
75
|
+
|
|
76
|
+
// Bung tiếp một lần chạy mới thấy nhật ký actions.
|
|
77
|
+
fireEvent.click(screen.getByText("Thành công"));
|
|
78
|
+
expect(screen.getByText("Gửi 3 tin ZNS")).toBeDefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("job chỉ-còn-trong-DB: khoá nút chạy/tạm dừng", async () => {
|
|
82
|
+
mockFetch([{ ...JOB, inMemory: false }]);
|
|
83
|
+
render(<SystemJobsPage />);
|
|
84
|
+
|
|
85
|
+
await waitFor(() => expect(screen.getByText("Chỉ lịch sử")).toBeDefined());
|
|
86
|
+
const actionButtons = screen
|
|
87
|
+
.getAllByRole("button")
|
|
88
|
+
.filter((b) => b.getAttribute("title")?.includes("Không khả dụng"));
|
|
89
|
+
expect(actionButtons).toHaveLength(2);
|
|
90
|
+
actionButtons.forEach((b) => expect(b.hasAttribute("disabled")).toBe(true));
|
|
91
|
+
});
|
|
92
|
+
});
|