@gadmin2n/schematics 0.0.70 → 0.0.72

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.
Files changed (28) hide show
  1. package/dist/lib/application/files/gadmin2-game-angle-demo/config/prisma/system.prisma +21 -0
  2. package/dist/lib/application/files/gadmin2-game-angle-demo/server/gadmin-cli.json +9 -1
  3. package/dist/lib/application/files/gadmin2-game-angle-demo/server/package-lock.json +15579 -0
  4. package/dist/lib/application/files/gadmin2-game-angle-demo/server/package.json +5 -3
  5. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/permissions.ts +16 -4
  6. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/app.module.ts +2 -0
  7. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/agenda/agenda.controller.ts +106 -0
  8. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/agenda/agenda.module.ts +10 -0
  9. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/agenda/agenda.service.ts +569 -0
  10. package/dist/lib/application/files/gadmin2-game-angle-demo/server/yarn.lock +1159 -481
  11. package/dist/lib/application/files/gadmin2-game-angle-demo/web/.env +1 -0
  12. package/dist/lib/application/files/gadmin2-game-angle-demo/web/package.json +1 -1
  13. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/agentPanel/inspectorActions.ts +130 -3
  14. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/canvas/NumCard/index.tsx +5 -5
  15. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/canvas/NumLineCard/index.tsx +2 -2
  16. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/config/routeRegistry.tsx +15 -0
  17. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/dev-shell/DevShell.tsx +55 -43
  18. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/dev-shell/SkillMenu.tsx +1 -1
  19. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/common.json +2 -4
  20. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/common.json +2 -4
  21. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/agenda/index.tsx +536 -0
  22. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/agenda/show.tsx +671 -0
  23. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasPage.tsx +9 -60
  24. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasToolbar.tsx +2 -2
  25. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/ComponentThumbnail.tsx +3 -1
  26. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/canvasContextMenuRegistry.tsx +11 -11
  27. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/vite-env.d.ts +12 -0
  28. package/package.json +1 -1
@@ -0,0 +1,569 @@
1
+ import { PostgresBackend } from '@agendajs/postgres-backend';
2
+ import {
3
+ Injectable,
4
+ Logger,
5
+ OnApplicationBootstrap,
6
+ OnApplicationShutdown,
7
+ } from '@nestjs/common';
8
+ import { ConfigService } from '@nestjs/config';
9
+ import { Agenda, Job } from 'agenda';
10
+ import type { JobsQueryOptions } from 'agenda/dist/types/JobQuery.js';
11
+ import { PrismaService } from 'nestjs-prisma';
12
+
13
+ interface GetJobsParams {
14
+ job?: string;
15
+ state?: string;
16
+ toggle?: string; // 'active' | 'paused'
17
+ module?: string;
18
+ q?: string;
19
+ skip?: number;
20
+ limit?: number;
21
+ }
22
+
23
+ @Injectable()
24
+ export class AgendaService
25
+ implements OnApplicationBootstrap, OnApplicationShutdown
26
+ {
27
+ private agenda: Agenda;
28
+ private readonly logger = new Logger(AgendaService.name);
29
+ private ready = false;
30
+
31
+ constructor(
32
+ private readonly configService: ConfigService,
33
+ private readonly prisma: PrismaService,
34
+ ) {
35
+ const databaseUrl =
36
+ process.env.DATABASE_URL ||
37
+ this.configService.get<string>('prisma.DATABASE_URL');
38
+ this.agenda = new Agenda({
39
+ backend: new PostgresBackend({ connectionString: databaseUrl }),
40
+ defaultConcurrency: 5,
41
+ maxConcurrency: 20,
42
+ });
43
+
44
+ this.agenda.on('error', (err) => {
45
+ this.logger.error('Agenda error', err);
46
+ });
47
+ }
48
+
49
+ async onApplicationBootstrap() {
50
+ await this.agenda.start();
51
+ this.ready = true;
52
+ this.logger.log('Agenda started');
53
+
54
+ if (process.env.NODE_ENV === 'development') {
55
+ await this.seedDevJobs();
56
+ }
57
+ }
58
+
59
+ /** 开发环境注入 mock job,方便 UI 调试 */
60
+ private async seedDevJobs() {
61
+ const prefix = 'dev-mock:';
62
+
63
+ // handler 每次启动都必须注册,否则重启后 agenda 找不到 handler
64
+ this.define(
65
+ `${prefix}sync-user-data`,
66
+ this.withLog(async (_job, log) => {
67
+ log('Fetching user data from upstream...');
68
+ await new Promise((r) => setTimeout(r, 2000));
69
+ log('Synced 128 users');
70
+ }),
71
+ );
72
+
73
+ this.define(
74
+ `${prefix}generate-daily-report`,
75
+ this.withLog(async (_job, log) => {
76
+ log('Generating daily report...');
77
+ await new Promise((r) => setTimeout(r, 3000));
78
+ log('Report generated: /reports/2026-05-18.pdf');
79
+ }),
80
+ );
81
+
82
+ this.define(
83
+ `${prefix}cleanup-expired-sessions`,
84
+ this.withLog(async (_job, log) => {
85
+ log('Cleaning up expired sessions...');
86
+ await new Promise((r) => setTimeout(r, 1000));
87
+ log('Removed 42 sessions');
88
+ }),
89
+ );
90
+
91
+ this.define(
92
+ `${prefix}send-notification-digest`,
93
+ this.withLog(async (_job, log) => {
94
+ log('Sending digest...');
95
+ await new Promise((r) => setTimeout(r, 1500));
96
+ log('Sent to 56 users');
97
+ }),
98
+ );
99
+
100
+ this.define(
101
+ `${prefix}check-health`,
102
+ this.withLog(async (_job, log) => {
103
+ log('Checking service health...');
104
+ await new Promise((r) => setTimeout(r, 500));
105
+ log('All services healthy');
106
+ }),
107
+ );
108
+
109
+ // 只在首次启动时创建 job,避免重复
110
+ const existing = await this.agenda.queryJobs({ search: prefix });
111
+ if (existing.total > 0) return;
112
+
113
+ // 创建 jobs:不同调度方式覆盖多种状态
114
+ await this.every(
115
+ '0 */6 * * *',
116
+ `${prefix}sync-user-data`,
117
+ {
118
+ source: 'mock',
119
+ },
120
+ 'user',
121
+ );
122
+ await this.every(
123
+ '0 2 * * *',
124
+ `${prefix}generate-daily-report`,
125
+ {
126
+ type: 'daily',
127
+ },
128
+ 'report',
129
+ );
130
+ await this.every(
131
+ '*/30 * * * *',
132
+ `${prefix}cleanup-expired-sessions`,
133
+ undefined,
134
+ 'system',
135
+ );
136
+ await this.schedule(
137
+ 'in 2 hours',
138
+ `${prefix}send-notification-digest`,
139
+ {
140
+ channel: 'email',
141
+ },
142
+ 'notification',
143
+ );
144
+ await this.now(`${prefix}check-health`, undefined, 'system');
145
+
146
+ this.logger.log('Dev mock jobs seeded');
147
+ }
148
+
149
+ async onApplicationShutdown() {
150
+ await this.agenda.stop();
151
+ this.logger.log('Agenda stopped');
152
+ }
153
+
154
+ /**
155
+ * 封装带日志的 job handler。
156
+ * 自动记录执行开始/结束/耗时/过程日志到 t_job_log,写日志出错不影响 handler 本身的执行结果。
157
+ *
158
+ * 用法:
159
+ * agendaService.define('send-email', agendaService.withLog(async (job, log) => {
160
+ * log('开始发送邮件...');
161
+ * const result = await sendEmail();
162
+ * log(`发送完成,共 ${result.count} 封`);
163
+ * }));
164
+ */
165
+ withLog(
166
+ handler: (job: Job, log: (message: string) => void) => Promise<void>,
167
+ ): (job: Job) => Promise<void> {
168
+ return async (job: Job) => {
169
+ const startAt = new Date();
170
+ const logs: string[] = [];
171
+ const log = (message: string) => {
172
+ const ts = new Date().toISOString();
173
+ logs.push(`[${ts}] ${message}`);
174
+ };
175
+ let error: string | undefined;
176
+ try {
177
+ await handler(job, log);
178
+ } catch (err) {
179
+ error = err instanceof Error ? err.message : String(err);
180
+ throw err; // 重新抛出,让 agenda 标记 failed
181
+ } finally {
182
+ const endAt = new Date();
183
+ const durationMs = endAt.getTime() - startAt.getTime();
184
+ this.prisma.jobLog
185
+ .create({
186
+ data: {
187
+ jobName: job.attrs.name,
188
+ status: error ? 'failed' : 'success',
189
+ data: (job.attrs.data as any) ?? undefined,
190
+ error: error ?? null,
191
+ logs: logs.length > 0 ? logs.join('\n') : null,
192
+ startAt,
193
+ endAt,
194
+ durationMs,
195
+ },
196
+ })
197
+ .catch((e) => {
198
+ this.logger.warn(
199
+ `Failed to write job log for "${job.attrs.name}": ${e?.message}`,
200
+ );
201
+ });
202
+ }
203
+ };
204
+ }
205
+
206
+ /** 定义任务处理器 */
207
+ define(
208
+ name: string,
209
+ handler: (job: Job) => Promise<void>,
210
+ options?: {
211
+ concurrency?: number;
212
+ priority?: number;
213
+ lockLifetime?: number;
214
+ },
215
+ ) {
216
+ this.agenda.define(name, handler, options);
217
+ }
218
+
219
+ /** 将 module 合并到 data 中 */
220
+ private mergeModule(
221
+ data?: Record<string, any>,
222
+ module?: string,
223
+ ): Record<string, any> | undefined {
224
+ if (!module) return data;
225
+ return { ...data, _module: module };
226
+ }
227
+
228
+ /** 立即执行 */
229
+ async now(name: string, data?: Record<string, any>, module?: string) {
230
+ return this.agenda.now(name, this.mergeModule(data, module));
231
+ }
232
+
233
+ /** 延迟/定时执行(一次性) */
234
+ async schedule(
235
+ when: string | Date,
236
+ name: string,
237
+ data?: Record<string, any>,
238
+ module?: string,
239
+ ) {
240
+ return this.agenda.schedule(when, name, this.mergeModule(data, module));
241
+ }
242
+
243
+ /** 周期执行 */
244
+ async every(
245
+ interval: string,
246
+ name: string,
247
+ data?: Record<string, any>,
248
+ module?: string,
249
+ ) {
250
+ return this.agenda.every(interval, name, this.mergeModule(data, module));
251
+ }
252
+
253
+ /** 取消任务 */
254
+ async cancel(query: Record<string, any>) {
255
+ return this.agenda.cancel(query);
256
+ }
257
+
258
+ /** 查询任务(低级接口) */
259
+ async queryJobs(query: Record<string, any>) {
260
+ return this.agenda.queryJobs(query);
261
+ }
262
+
263
+ /** 返回已通过 define() 注册的 job 名称列表 */
264
+ getDefinedJobNames(): string[] {
265
+ return Object.keys((this.agenda as any).definitions ?? {});
266
+ }
267
+
268
+ // ─── UI management methods ────────────────────────────────────────────────
269
+
270
+ /** 获取任务列表 + 各状态概览 */
271
+ async getJobs(params: GetJobsParams) {
272
+ if (!this.ready) {
273
+ return {
274
+ overview: {
275
+ running: 0,
276
+ scheduled: 0,
277
+ queued: 0,
278
+ completed: 0,
279
+ failed: 0,
280
+ repeating: 0,
281
+ paused: 0,
282
+ },
283
+ jobs: [],
284
+ total: 0,
285
+ totalPages: 0,
286
+ };
287
+ }
288
+ const { job, state, toggle, module, q, skip = 0, limit = 20 } = params;
289
+
290
+ const allQuery: JobsQueryOptions = {};
291
+ if (job) allQuery.name = job;
292
+ if (q) allQuery.search = q;
293
+
294
+ const allResult = await this.agenda.queryJobs(allQuery);
295
+
296
+ // 应用层过滤 module:Agenda queryJobs API 仅支持 name/state/search,
297
+ // 不支持按 data 内字段查询。Job 数量通常较少(几十~几百),应用层过滤足够。
298
+ // 若将来 job 量大幅增长,可直接查 Agenda 底层 PostgreSQL 表用 JSONB 查询替代。
299
+ const moduleFiltered = module
300
+ ? allResult.jobs.filter(
301
+ (j) => (j.data as Record<string, any>)?._module === module,
302
+ )
303
+ : allResult.jobs;
304
+
305
+ const overview: Record<string, number> = {
306
+ running: 0,
307
+ scheduled: 0,
308
+ queued: 0,
309
+ completed: 0,
310
+ failed: 0,
311
+ repeating: 0,
312
+ paused: 0,
313
+ active: 0,
314
+ };
315
+
316
+ for (const j of moduleFiltered) {
317
+ const s = j.state;
318
+ if (s in overview) overview[s]++;
319
+ }
320
+
321
+ // 第一步:按 job 原始状态过滤(running/queued 等)
322
+ const stateFiltered = state
323
+ ? moduleFiltered.filter((j) => j.state === state)
324
+ : moduleFiltered;
325
+
326
+ // 从状态过滤后的数据计算 active/paused 数量
327
+ for (const j of stateFiltered) {
328
+ if (j.disabled) overview.paused++;
329
+ else overview.active++;
330
+ }
331
+
332
+ // 第二步:按 active/paused 过滤
333
+ const toggleFiltered = toggle
334
+ ? toggle === 'active'
335
+ ? stateFiltered.filter((j) => !j.disabled)
336
+ : toggle === 'paused'
337
+ ? stateFiltered.filter((j) => j.disabled)
338
+ : stateFiltered
339
+ : stateFiltered;
340
+
341
+ const total = toggleFiltered.length;
342
+ const paged = toggleFiltered.slice(skip, skip + limit);
343
+
344
+ // 批量查询当前页 jobs 的历史执行统计(success/failed 来自 job log)
345
+ const jobNames = paged.map((j) => j.name);
346
+ const runStats = jobNames.length
347
+ ? await this.prisma.jobLog.groupBy({
348
+ by: ['jobName', 'status'],
349
+ where: { jobName: { in: jobNames } },
350
+ _count: true,
351
+ })
352
+ : [];
353
+
354
+ const logMap = new Map<string, { success: number; failed: number }>();
355
+ for (const row of runStats) {
356
+ const entry = logMap.get(row.jobName) ?? { success: 0, failed: 0 };
357
+ if (row.status === 'success') entry.success = row._count;
358
+ else if (row.status === 'failed') entry.failed = row._count;
359
+ logMap.set(row.jobName, entry);
360
+ }
361
+
362
+ // queued/running 数量:统计同名 job 中处于该状态的条数
363
+ const queuedRunningMap = new Map<
364
+ string,
365
+ { queued: number; running: number }
366
+ >();
367
+ for (const j of allResult.jobs) {
368
+ if (j.state !== 'queued' && j.state !== 'running') continue;
369
+ const entry = queuedRunningMap.get(j.name) ?? {
370
+ queued: 0,
371
+ running: 0,
372
+ };
373
+ entry[j.state as 'queued' | 'running']++;
374
+ queuedRunningMap.set(j.name, entry);
375
+ }
376
+
377
+ const jobs = paged.map((j) => {
378
+ const rawData = (j.data as Record<string, any>) ?? null;
379
+ const mod = rawData?._module ?? null;
380
+ const log = logMap.get(j.name) ?? { success: 0, failed: 0 };
381
+ const qr = queuedRunningMap.get(j.name) ?? { queued: 0, running: 0 };
382
+ return {
383
+ id: String(j._id),
384
+ name: j.name,
385
+ module: mod,
386
+ state: j.state,
387
+ runs: { queued: qr.queued, running: qr.running, ...log },
388
+ nextRunAt: j.nextRunAt,
389
+ lastRunAt: j.lastRunAt,
390
+ lastFinishedAt: j.lastFinishedAt,
391
+ failReason: (j as any).failReason ?? null,
392
+ failedAt: j.failedAt ?? null,
393
+ repeatInterval: j.repeatInterval ?? null,
394
+ data: rawData,
395
+ lockedAt: j.lockedAt ?? null,
396
+ disabled: j.disabled ?? false,
397
+ };
398
+ });
399
+
400
+ // 收集所有 distinct module(从未过滤的全量 jobs 中提取)
401
+ const modules = [
402
+ ...new Set(
403
+ allResult.jobs
404
+ .map((j) => (j.data as Record<string, any>)?._module)
405
+ .filter(Boolean) as string[],
406
+ ),
407
+ ].sort();
408
+
409
+ return {
410
+ overview,
411
+ modules,
412
+ jobs,
413
+ total,
414
+ totalPages: Math.ceil(total / limit),
415
+ };
416
+ }
417
+
418
+ /** Requeue jobs — reschedule them to run immediately */
419
+ async requeueByIds(jobIds: string[]) {
420
+ let affected = 0;
421
+ for (const id of jobIds) {
422
+ const result = await this.agenda.queryJobs({ id });
423
+ if (result.jobs.length > 0) {
424
+ const job = result.jobs[0];
425
+ const liveJobs = await this.agenda.queryJobs({ id } as any);
426
+ if (liveJobs.jobs.length > 0) {
427
+ await this.agenda.cancel({ _id: id } as any);
428
+ await this.agenda.now(job.name, job.data as Record<string, any>);
429
+ affected++;
430
+ }
431
+ }
432
+ }
433
+ return { affected };
434
+ }
435
+
436
+ /** Retry failed jobs */
437
+ async retryByIds(jobIds: string[]) {
438
+ let affected = 0;
439
+ for (const id of jobIds) {
440
+ const result = await this.agenda.queryJobs({ id });
441
+ if (result.jobs.length > 0) {
442
+ const job = result.jobs[0];
443
+ await this.agenda.cancel({ _id: id } as any);
444
+ await this.agenda.now(job.name, job.data as Record<string, any>);
445
+ affected++;
446
+ }
447
+ }
448
+ return { affected };
449
+ }
450
+
451
+ /** Delete jobs by IDs */
452
+ async deleteByIds(jobIds: string[]) {
453
+ let affected = 0;
454
+ for (const id of jobIds) {
455
+ const count = await this.agenda.cancel({ _id: id } as any);
456
+ affected += count ?? 0;
457
+ }
458
+ return { affected };
459
+ }
460
+
461
+ /** Disable (pause) jobs by IDs */
462
+ async disableByIds(jobIds: string[]) {
463
+ let affected = 0;
464
+ for (const id of jobIds) {
465
+ const count = await this.agenda.disable({ id });
466
+ affected += count ?? 0;
467
+ }
468
+ return { affected };
469
+ }
470
+
471
+ /** Enable (resume) jobs by IDs */
472
+ async enableByIds(jobIds: string[]) {
473
+ let affected = 0;
474
+ for (const id of jobIds) {
475
+ const count = await this.agenda.enable({ id });
476
+ affected += count ?? 0;
477
+ }
478
+ return { affected };
479
+ }
480
+
481
+ /** 查询某个 job 的执行历史 */
482
+ async getJobLogs(params: {
483
+ jobName: string;
484
+ status?: string;
485
+ before?: string;
486
+ skip?: number;
487
+ limit?: number;
488
+ }) {
489
+ const { jobName, status, before, skip = 0, limit = 20 } = params;
490
+ const where: Record<string, any> = { jobName };
491
+ if (status) where.status = status;
492
+ if (before) where.startAt = { lte: new Date(before) };
493
+ const [logs, total] = await Promise.all([
494
+ this.prisma.jobLog.findMany({
495
+ where,
496
+ orderBy: { startAt: 'desc' },
497
+ skip,
498
+ take: limit,
499
+ }),
500
+ this.prisma.jobLog.count({ where }),
501
+ ]);
502
+ return { logs, total };
503
+ }
504
+
505
+ /** 获取单个 job 详情 + 运行统计 */
506
+ async getJobDetail(jobName: string) {
507
+ // 查询 agenda 中该 job
508
+ const result = await this.agenda.queryJobs({ name: jobName });
509
+ if (result.jobs.length === 0) return null;
510
+
511
+ const j = result.jobs[0];
512
+ const rawData = (j.data as Record<string, any>) ?? null;
513
+
514
+ // queued/running 数量:同名 job 的状态统计
515
+ let queued = 0;
516
+ let running = 0;
517
+ for (const job of result.jobs) {
518
+ if (job.state === 'queued') queued++;
519
+ if (job.state === 'running') running++;
520
+ }
521
+
522
+ // 从 t_job_log 聚合统计
523
+ const [logStats, logCounts] = await Promise.all([
524
+ this.prisma.jobLog.aggregate({
525
+ where: { jobName },
526
+ _max: { durationMs: true, startAt: true },
527
+ _min: { durationMs: true, startAt: true },
528
+ _avg: { durationMs: true },
529
+ _count: true,
530
+ }),
531
+ this.prisma.jobLog.groupBy({
532
+ by: ['status'],
533
+ where: { jobName },
534
+ _count: true,
535
+ }),
536
+ ]);
537
+
538
+ let success = 0;
539
+ let failed = 0;
540
+ for (const row of logCounts) {
541
+ if (row.status === 'success') success = row._count;
542
+ else if (row.status === 'failed') failed = row._count;
543
+ }
544
+
545
+ return {
546
+ name: j.name,
547
+ module: rawData?._module ?? null,
548
+ state: j.state,
549
+ disabled: j.disabled ?? false,
550
+ nextRunAt: j.nextRunAt,
551
+ lastRunAt: j.lastRunAt,
552
+ lastFinishedAt: j.lastFinishedAt,
553
+ failReason: (j as any).failReason ?? null,
554
+ repeatInterval: j.repeatInterval ?? null,
555
+ data: rawData,
556
+ runs: { queued, running, success, failed },
557
+ duration: {
558
+ totalRuns: logStats._count,
559
+ maxMs: logStats._max.durationMs,
560
+ minMs: logStats._min.durationMs,
561
+ avgMs: logStats._avg.durationMs
562
+ ? Math.round(logStats._avg.durationMs)
563
+ : null,
564
+ firstRunAt: logStats._min.startAt,
565
+ lastRunAt: logStats._max.startAt,
566
+ },
567
+ };
568
+ }
569
+ }