@goplusvn/core 0.1.53 → 0.1.55

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.
@@ -0,0 +1,571 @@
1
+ "use client";
2
+
3
+ // Trang admin "Tác vụ định kỳ" dùng chung mọi app goerp (F1 batch 4) — hiển thị
4
+ // job đang đăng ký trong tiến trình + trạng thái DB, bật/tắt, chạy tay, và mở
5
+ // lịch sử thực thi từng lần. Engine: `@goerp/core/cron`
6
+ // (`configureCronManager`) + feature `system-jobs` (goerp-features sync).
7
+ // API do app cung cấp (mẫu vinhhoa: /api/admin/system/jobs).
8
+
9
+ import { Fragment, useCallback, useEffect, useState } from "react";
10
+ import { toast } from "sonner";
11
+ import {
12
+ Button,
13
+ Table,
14
+ TableBody,
15
+ TableCell,
16
+ TableHead,
17
+ TableHeader,
18
+ TableRow,
19
+ } from "../../ui/primitives";
20
+ import { CompactStatBar } from "../../ui/data-display/compact-stat-bar";
21
+ import type { CompactStatItem } from "../../ui/data-display/compact-stat-bar";
22
+ import {
23
+ Activity,
24
+ AlertTriangle,
25
+ ChevronDown,
26
+ ChevronLeft,
27
+ ChevronRight,
28
+ ChevronUp,
29
+ Clock,
30
+ Loader2,
31
+ Pause,
32
+ Play,
33
+ RefreshCw,
34
+ Terminal,
35
+ Timer,
36
+ Zap,
37
+ } from "lucide-react";
38
+
39
+ interface SystemJob {
40
+ name: string;
41
+ cronTime: string;
42
+ isRunning: boolean;
43
+ nextDate: string | null;
44
+ enabled: boolean;
45
+ status: "idle" | "running" | "failed" | "stopped";
46
+ lastRun: string | null;
47
+ nextRun: string | null;
48
+ error: string | null;
49
+ // False = job chỉ còn trong DB (lịch sử), không đăng ký trong tiến trình hiện
50
+ // tại → không thể chạy/tạm dừng, nhưng vẫn xem lại được lịch sử thực thi.
51
+ inMemory?: boolean;
52
+ }
53
+
54
+ interface ExecutionLog {
55
+ id: string;
56
+ jobName: string;
57
+ startedAt: string;
58
+ finishedAt: string | null;
59
+ durationMs: number | null;
60
+ status: "running" | "success" | "failed";
61
+ error: string | null;
62
+ summary: string | null;
63
+ actions: Array<{ time: string; action: string; details?: string }> | null;
64
+ }
65
+
66
+ function formatDuration(ms: number | null): string {
67
+ if (!ms) return "-";
68
+ if (ms < 1000) return `${ms}ms`;
69
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
70
+ return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
71
+ }
72
+
73
+ function formatDateTime(s: string | null): string {
74
+ if (!s) return "-";
75
+ return new Date(s).toLocaleString("vi-VN", {
76
+ day: "2-digit",
77
+ month: "2-digit",
78
+ year: "numeric",
79
+ hour: "2-digit",
80
+ minute: "2-digit",
81
+ second: "2-digit",
82
+ });
83
+ }
84
+
85
+ function StatusBadge({
86
+ status,
87
+ }: {
88
+ status: SystemJob["status"] | ExecutionLog["status"];
89
+ }) {
90
+ const map: Record<string, { label: string; className: string }> = {
91
+ idle: { label: "Chờ", className: "bg-muted text-muted-foreground" },
92
+ stopped: { label: "Dừng", className: "bg-muted text-muted-foreground" },
93
+ running: { label: "Đang chạy", className: "bg-info-subtle text-info-text" },
94
+ failed: { label: "Lỗi", className: "bg-danger-subtle text-danger-text" },
95
+ success: {
96
+ label: "Thành công",
97
+ className: "bg-success-subtle text-success-text",
98
+ },
99
+ };
100
+ const cfg = map[status] || {
101
+ label: status,
102
+ className: "bg-muted text-muted-foreground",
103
+ };
104
+ return (
105
+ <span
106
+ className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${cfg.className}`}
107
+ >
108
+ {status === "running" && <RefreshCw className="h-3 w-3 animate-spin" />}
109
+ {status === "failed" && <AlertTriangle className="h-3 w-3" />}
110
+ {cfg.label}
111
+ </span>
112
+ );
113
+ }
114
+
115
+ function ExecutionHistoryPanel({
116
+ jobName,
117
+ apiUrl,
118
+ }: {
119
+ jobName: string;
120
+ apiUrl: string;
121
+ }) {
122
+ const [logs, setLogs] = useState<ExecutionLog[]>([]);
123
+ const [total, setTotal] = useState(0);
124
+ const [page, setPage] = useState(0);
125
+ const [loading, setLoading] = useState(true);
126
+ const [error, setError] = useState<string | null>(null);
127
+ const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
128
+
129
+ const PAGE_SIZE = 10;
130
+
131
+ const fetchHistory = useCallback(async () => {
132
+ setLoading(true);
133
+ setError(null);
134
+ try {
135
+ const res = await fetch(
136
+ `${apiUrl}/${encodeURIComponent(jobName)}/history?skip=${page * PAGE_SIZE}&take=${PAGE_SIZE}`,
137
+ );
138
+ if (res.ok) {
139
+ const data = await res.json();
140
+ setLogs(data.data || []);
141
+ setTotal(data.meta?.total || 0);
142
+ } else {
143
+ // Phân biệt "lỗi tải" với "không có dữ liệu" — tránh báo nhầm "chưa có lịch sử".
144
+ setError(`Không tải được lịch sử (HTTP ${res.status})`);
145
+ }
146
+ } catch {
147
+ setError("Không tải được lịch sử (lỗi kết nối)");
148
+ } finally {
149
+ setLoading(false);
150
+ }
151
+ }, [apiUrl, jobName, page]);
152
+
153
+ useEffect(() => {
154
+ fetchHistory();
155
+ }, [fetchHistory]);
156
+
157
+ const toggleExpand = (id: string) =>
158
+ setExpandedIds((prev) => {
159
+ const next = new Set(prev);
160
+ if (next.has(id)) next.delete(id);
161
+ else next.add(id);
162
+ return next;
163
+ });
164
+
165
+ const totalPages = Math.ceil(total / PAGE_SIZE);
166
+
167
+ return (
168
+ <div className="space-y-2">
169
+ <div className="flex items-center justify-between">
170
+ <span className="text-xs text-muted-foreground">
171
+ {total} lần thực thi
172
+ </span>
173
+ <Button
174
+ variant="ghost"
175
+ size="sm"
176
+ className="h-6 text-xs"
177
+ onClick={fetchHistory}
178
+ >
179
+ <RefreshCw className="h-3 w-3 mr-1" /> Tải lại
180
+ </Button>
181
+ </div>
182
+
183
+ {loading ? (
184
+ <div className="flex justify-center py-4">
185
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
186
+ </div>
187
+ ) : error ? (
188
+ <div className="flex items-center justify-center gap-1.5 py-6 text-xs text-destructive">
189
+ <AlertTriangle className="h-3.5 w-3.5" />
190
+ {error}
191
+ </div>
192
+ ) : logs.length === 0 ? (
193
+ <div className="text-center py-6 text-xs text-muted-foreground">
194
+ Chưa có lịch sử thực thi
195
+ </div>
196
+ ) : (
197
+ <div className="space-y-1">
198
+ {logs.map((log) => {
199
+ const isExpanded = expandedIds.has(log.id);
200
+ const actions = Array.isArray(log.actions) ? log.actions : [];
201
+ return (
202
+ <div key={log.id} className="rounded border bg-card text-sm">
203
+ <div
204
+ className="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-muted/40"
205
+ onClick={() => toggleExpand(log.id)}
206
+ >
207
+ <StatusBadge status={log.status} />
208
+ <span className="text-xs text-muted-foreground font-mono">
209
+ {formatDateTime(log.startedAt)}
210
+ </span>
211
+ <span className="flex items-center gap-1 text-xs text-muted-foreground ml-auto">
212
+ <Timer className="h-3 w-3" />
213
+ {formatDuration(log.durationMs)}
214
+ </span>
215
+ {actions.length > 0 && (
216
+ <span className="text-xs text-muted-foreground">
217
+ {actions.length} actions
218
+ </span>
219
+ )}
220
+ {isExpanded ? (
221
+ <ChevronUp className="h-3 w-3 text-muted-foreground" />
222
+ ) : (
223
+ <ChevronDown className="h-3 w-3 text-muted-foreground" />
224
+ )}
225
+ </div>
226
+
227
+ {isExpanded && (
228
+ <div className="border-t px-3 py-2 space-y-2 bg-muted/20">
229
+ {log.error && (
230
+ <div className="rounded bg-danger-subtle border border-danger-border px-2 py-1.5">
231
+ <span className="text-xs font-medium text-danger-text">
232
+ Lỗi:{" "}
233
+ </span>
234
+ <span className="text-xs text-danger-text">
235
+ {log.error}
236
+ </span>
237
+ </div>
238
+ )}
239
+ {log.summary && (
240
+ <p className="text-xs text-muted-foreground">
241
+ {log.summary}
242
+ </p>
243
+ )}
244
+ {actions.length > 0 && (
245
+ <div className="space-y-0.5">
246
+ <p className="text-xs font-medium text-muted-foreground mb-1">
247
+ <Terminal className="h-3 w-3 inline mr-1" />
248
+ Actions log:
249
+ </p>
250
+ {actions.map((a, i) => (
251
+ <div key={i} className="flex gap-2 text-xs font-mono">
252
+ <span className="text-muted-foreground shrink-0">
253
+ {new Date(a.time).toLocaleTimeString("vi-VN")}
254
+ </span>
255
+ <span className="text-foreground">{a.action}</span>
256
+ {a.details && (
257
+ <span className="text-muted-foreground">
258
+ {a.details}
259
+ </span>
260
+ )}
261
+ </div>
262
+ ))}
263
+ </div>
264
+ )}
265
+ </div>
266
+ )}
267
+ </div>
268
+ );
269
+ })}
270
+ </div>
271
+ )}
272
+
273
+ {totalPages > 1 && (
274
+ <div className="flex items-center justify-between pt-1">
275
+ <span className="text-xs text-muted-foreground">
276
+ Trang {page + 1}/{totalPages}
277
+ </span>
278
+ <div className="flex gap-1">
279
+ <Button
280
+ variant="outline"
281
+ size="sm"
282
+ className="h-6 w-6 p-0"
283
+ disabled={page === 0}
284
+ onClick={() => setPage((p) => p - 1)}
285
+ >
286
+ <ChevronLeft className="h-3 w-3" />
287
+ </Button>
288
+ <Button
289
+ variant="outline"
290
+ size="sm"
291
+ className="h-6 w-6 p-0"
292
+ disabled={page >= totalPages - 1}
293
+ onClick={() => setPage((p) => p + 1)}
294
+ >
295
+ <ChevronRight className="h-3 w-3" />
296
+ </Button>
297
+ </div>
298
+ </div>
299
+ )}
300
+ </div>
301
+ );
302
+ }
303
+
304
+ export function SystemJobsPage({
305
+ apiUrl = "/api/admin/system/jobs",
306
+ }: {
307
+ /** Endpoint app cung cấp: GET danh sách, POST {name, action}, GET <name>/history. */
308
+ apiUrl?: string;
309
+ } = {}) {
310
+ const [jobs, setJobs] = useState<SystemJob[]>([]);
311
+ const [loading, setLoading] = useState(true);
312
+ const [expandedJob, setExpandedJob] = useState<string | null>(null);
313
+ const [actionLoading, setActionLoading] = useState<string | null>(null);
314
+
315
+ // silent = poll nền: KHÔNG bật loading / KHÔNG toast lỗi → bảng không nhấp nháy.
316
+ const fetchJobs = useCallback(
317
+ async (silent = false) => {
318
+ if (!silent) setLoading(true);
319
+ try {
320
+ const res = await fetch(apiUrl);
321
+ if (res.ok) {
322
+ const data = await res.json();
323
+ setJobs(data.data || []);
324
+ }
325
+ } catch {
326
+ if (!silent) toast.error("Không thể tải danh sách jobs");
327
+ } finally {
328
+ if (!silent) setLoading(false);
329
+ }
330
+ },
331
+ [apiUrl],
332
+ );
333
+
334
+ useEffect(() => {
335
+ fetchJobs(); // lần đầu: hiện loading
336
+ const interval = setInterval(() => fetchJobs(true), 15000); // poll nền im lặng mỗi 15s
337
+ return () => clearInterval(interval);
338
+ }, [fetchJobs]);
339
+
340
+ const handleAction = async (name: string, action: "toggle" | "run") => {
341
+ setActionLoading(`${name}-${action}`);
342
+ try {
343
+ const res = await fetch(apiUrl, {
344
+ method: "POST",
345
+ headers: { "Content-Type": "application/json" },
346
+ body: JSON.stringify({ name, action }),
347
+ });
348
+ const data = await res.json();
349
+ if (data.success) {
350
+ toast.success(data.message);
351
+ await fetchJobs();
352
+ } else {
353
+ toast.error(data.error || "Thao tác thất bại");
354
+ }
355
+ } catch {
356
+ toast.error("Lỗi kết nối máy chủ");
357
+ } finally {
358
+ setActionLoading(null);
359
+ }
360
+ };
361
+
362
+ const totalJobs = jobs.length;
363
+ const runningJobs = jobs.filter((j) => j.isRunning).length;
364
+ const failedJobs = jobs.filter((j) => j.status === "failed").length;
365
+
366
+ const statItems: CompactStatItem[] = [
367
+ {
368
+ id: "total",
369
+ label: "TỔNG JOBS",
370
+ value: totalJobs,
371
+ icon: Clock,
372
+ colorTheme: "dark",
373
+ },
374
+ {
375
+ id: "running",
376
+ label: "ĐANG CHẠY",
377
+ value: runningJobs,
378
+ icon: Activity,
379
+ colorTheme: "blue",
380
+ },
381
+ {
382
+ id: "failed",
383
+ label: "LỖI",
384
+ value: failedJobs,
385
+ icon: AlertTriangle,
386
+ colorTheme: "destructive",
387
+ },
388
+ ];
389
+
390
+ return (
391
+ <div className="flex flex-col h-full gap-2 overflow-hidden">
392
+ <CompactStatBar items={statItems} />
393
+
394
+ {/* Toolbar */}
395
+ <div className="flex items-center justify-end shrink-0">
396
+ <Button
397
+ variant="outline"
398
+ size="sm"
399
+ onClick={() => fetchJobs()}
400
+ disabled={loading}
401
+ >
402
+ <RefreshCw
403
+ className={`h-4 w-4 mr-1 ${loading ? "animate-spin" : ""}`}
404
+ />
405
+ Làm mới
406
+ </Button>
407
+ </div>
408
+
409
+ {/* Jobs table */}
410
+ <div className="flex-1 min-h-0 overflow-auto border bg-card rounded-md shadow-sm">
411
+ {loading && jobs.length === 0 ? (
412
+ <div className="flex items-center justify-center py-16">
413
+ <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
414
+ <span className="ml-2 text-sm text-muted-foreground">
415
+ Đang tải...
416
+ </span>
417
+ </div>
418
+ ) : (
419
+ <Table>
420
+ <TableHeader>
421
+ <TableRow>
422
+ <TableHead className="w-8" />
423
+ <TableHead>Tên Job</TableHead>
424
+ <TableHead className="w-[140px]">Lịch chạy</TableHead>
425
+ <TableHead className="w-[110px]">Trạng thái</TableHead>
426
+ <TableHead className="w-[160px]">Lần chạy cuối</TableHead>
427
+ <TableHead className="w-[160px]">Lần chạy kế</TableHead>
428
+ <TableHead className="w-[130px] text-right">Thao tác</TableHead>
429
+ </TableRow>
430
+ </TableHeader>
431
+ <TableBody>
432
+ {jobs.length === 0 ? (
433
+ <TableRow>
434
+ <TableCell
435
+ colSpan={7}
436
+ className="text-center py-10 text-muted-foreground"
437
+ >
438
+ Chưa có jobs nào được đăng ký
439
+ </TableCell>
440
+ </TableRow>
441
+ ) : (
442
+ jobs.map((job) => (
443
+ <Fragment key={job.name}>
444
+ <TableRow
445
+ className="cursor-pointer"
446
+ onClick={() =>
447
+ setExpandedJob(
448
+ expandedJob === job.name ? null : job.name,
449
+ )
450
+ }
451
+ >
452
+ <TableCell className="w-8 pr-0">
453
+ {expandedJob === job.name ? (
454
+ <ChevronUp className="h-3.5 w-3.5 text-muted-foreground" />
455
+ ) : (
456
+ <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
457
+ )}
458
+ </TableCell>
459
+ <TableCell>
460
+ <div className="flex items-center gap-1.5">
461
+ <span className="font-medium text-sm">
462
+ {job.name}
463
+ </span>
464
+ {job.inMemory === false && (
465
+ <span
466
+ className="rounded-full bg-warning-subtle px-1.5 py-0.5 text-[10px] font-medium text-warning-text"
467
+ title="Job không đăng ký trong tiến trình này — chỉ xem lịch sử, không chạy/tạm dừng được"
468
+ >
469
+ Chỉ lịch sử
470
+ </span>
471
+ )}
472
+ </div>
473
+ {job.error && (
474
+ <div
475
+ className="text-xs text-destructive mt-0.5 truncate max-w-[220px]"
476
+ title={job.error}
477
+ >
478
+ {job.error}
479
+ </div>
480
+ )}
481
+ </TableCell>
482
+ <TableCell>
483
+ <code className="text-xs bg-muted px-1.5 py-0.5 rounded font-mono">
484
+ {job.cronTime}
485
+ </code>
486
+ </TableCell>
487
+ <TableCell>
488
+ <StatusBadge status={job.status} />
489
+ </TableCell>
490
+ <TableCell className="text-xs text-muted-foreground font-mono">
491
+ {formatDateTime(job.lastRun)}
492
+ </TableCell>
493
+ <TableCell className="text-xs text-muted-foreground font-mono">
494
+ {formatDateTime(job.nextRun || job.nextDate)}
495
+ </TableCell>
496
+ <TableCell
497
+ className="text-right"
498
+ onClick={(e) => e.stopPropagation()}
499
+ >
500
+ <div className="flex items-center justify-end gap-1">
501
+ {/* Chạy ngay */}
502
+ <Button
503
+ variant="outline"
504
+ size="sm"
505
+ className="h-7 text-xs"
506
+ disabled={!!actionLoading || job.inMemory === false}
507
+ onClick={() => handleAction(job.name, "run")}
508
+ title={
509
+ job.inMemory === false
510
+ ? "Không khả dụng — job không đăng ký trong tiến trình này"
511
+ : "Chạy ngay"
512
+ }
513
+ >
514
+ {actionLoading === `${job.name}-run` ? (
515
+ <Loader2 className="h-3 w-3 animate-spin" />
516
+ ) : (
517
+ <Zap className="h-3 w-3" />
518
+ )}
519
+ </Button>
520
+ {/* Bật/tắt */}
521
+ <Button
522
+ variant={job.enabled ? "default" : "outline"}
523
+ size="sm"
524
+ className="h-7 text-xs"
525
+ disabled={!!actionLoading || job.inMemory === false}
526
+ onClick={() => handleAction(job.name, "toggle")}
527
+ title={
528
+ job.inMemory === false
529
+ ? "Không khả dụng — job không đăng ký trong tiến trình này"
530
+ : job.enabled
531
+ ? "Tạm dừng"
532
+ : "Bật lại"
533
+ }
534
+ >
535
+ {actionLoading === `${job.name}-toggle` ? (
536
+ <Loader2 className="h-3 w-3 animate-spin" />
537
+ ) : job.enabled ? (
538
+ <Pause className="h-3 w-3" />
539
+ ) : (
540
+ <Play className="h-3 w-3" />
541
+ )}
542
+ </Button>
543
+ </div>
544
+ </TableCell>
545
+ </TableRow>
546
+
547
+ {/* Hàng lịch sử thực thi */}
548
+ {expandedJob === job.name && (
549
+ <TableRow>
550
+ <TableCell />
551
+ <TableCell
552
+ colSpan={6}
553
+ className="bg-muted/30 px-4 py-3"
554
+ >
555
+ <ExecutionHistoryPanel
556
+ jobName={job.name}
557
+ apiUrl={apiUrl}
558
+ />
559
+ </TableCell>
560
+ </TableRow>
561
+ )}
562
+ </Fragment>
563
+ ))
564
+ )}
565
+ </TableBody>
566
+ </Table>
567
+ )}
568
+ </div>
569
+ </div>
570
+ );
571
+ }