@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,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
+ });
@@ -0,0 +1,532 @@
1
+ "use client";
2
+
3
+ import {
4
+ CalendarDays,
5
+ ChevronDown,
6
+ ChevronLeft,
7
+ ChevronRight,
8
+ ChevronUp,
9
+ Filter,
10
+ Loader2,
11
+ RefreshCw,
12
+ ScrollText,
13
+ Search,
14
+ X,
15
+ } from "lucide-react";
16
+ import { useCallback, useEffect, useRef, useState } from "react";
17
+
18
+ import {
19
+ Badge,
20
+ Button,
21
+ Card,
22
+ CardContent,
23
+ CardHeader,
24
+ CardTitle,
25
+ Input,
26
+ Select,
27
+ SelectContent,
28
+ SelectItem,
29
+ SelectTrigger,
30
+ SelectValue,
31
+ Table,
32
+ TableBody,
33
+ TableCell,
34
+ TableHead,
35
+ TableHeader,
36
+ TableRow,
37
+ } from "../../ui";
38
+
39
+ /**
40
+ * Trang "Nhật ký hệ thống" — đọc bảng `audit_logs` (feature `audit-logs`).
41
+ * Lọc SERVER-SIDE hết (search/action/resource/khoảng ngày + phân trang): bảng
42
+ * này nhanh chóng lên hàng triệu dòng, lọc ở client là tải chết trình duyệt.
43
+ *
44
+ * `actions`/`resources` là DỮ LIỆU CỦA APP (nhãn tiếng Việt + màu badge cho
45
+ * hành động nghiệp vụ riêng) — core chỉ thủ sẵn create/update/delete.
46
+ */
47
+
48
+ export interface AuditActionOption {
49
+ value: string;
50
+ label: string;
51
+ /** Class nền badge, vd "bg-emerald-500". */
52
+ color?: string;
53
+ }
54
+
55
+ export interface AuditResourceOption {
56
+ value: string;
57
+ label: string;
58
+ }
59
+
60
+ /** Ba hành động extension tự sinh — app nào cũng có. */
61
+ export const BASE_AUDIT_ACTIONS: AuditActionOption[] = [
62
+ { value: "create", label: "Tạo mới", color: "bg-emerald-500" },
63
+ { value: "update", label: "Cập nhật", color: "bg-blue-500" },
64
+ { value: "delete", label: "Xóa", color: "bg-red-500" },
65
+ ];
66
+
67
+ interface AuditLogEntry {
68
+ id: string;
69
+ action: string;
70
+ resource: string;
71
+ resourceId: string | null;
72
+ userId: string | null;
73
+ description: string | null;
74
+ oldData: Record<string, unknown> | null;
75
+ newData: Record<string, unknown> | null;
76
+ createdAt: string;
77
+ user?: { id: string; name: string | null; email: string | null } | null;
78
+ }
79
+
80
+ interface AuditResponse {
81
+ data: AuditLogEntry[];
82
+ meta: { total: number; skip: number; take: number; hasMore: boolean };
83
+ }
84
+
85
+ const PAGE_SIZE = 30;
86
+
87
+ function formatTime(dateStr: string) {
88
+ return new Date(dateStr).toLocaleString("vi-VN", {
89
+ day: "2-digit",
90
+ month: "2-digit",
91
+ year: "numeric",
92
+ hour: "2-digit",
93
+ minute: "2-digit",
94
+ second: "2-digit",
95
+ });
96
+ }
97
+
98
+ function DataChanges({
99
+ oldData,
100
+ newData,
101
+ }: {
102
+ oldData: Record<string, unknown> | null;
103
+ newData: Record<string, unknown> | null;
104
+ }) {
105
+ if (!oldData && !newData) return null;
106
+ const hideKeys = new Set([
107
+ "id",
108
+ "createdAt",
109
+ "updatedAt",
110
+ "updatedBy",
111
+ "createdBy",
112
+ ]);
113
+ const allKeys = Array.from(
114
+ new Set([...Object.keys(oldData || {}), ...Object.keys(newData || {})]),
115
+ ).filter((k) => !hideKeys.has(k));
116
+
117
+ const changedKeys = allKeys.filter((k) => {
118
+ const ov = oldData?.[k],
119
+ nv = newData?.[k];
120
+ if (JSON.stringify(ov) === JSON.stringify(nv)) return false;
121
+ if (typeof ov === "object" && typeof nv === "object") return false;
122
+ return true;
123
+ });
124
+
125
+ if (changedKeys.length === 0) return null;
126
+
127
+ return (
128
+ <div className="space-y-0.5 mt-1">
129
+ {changedKeys.slice(0, 8).map((key) => {
130
+ const ov = oldData?.[key],
131
+ nv = newData?.[key];
132
+ return (
133
+ <div key={key} className="flex items-start gap-1.5 text-xs">
134
+ <span className="text-muted-foreground font-medium min-w-[80px] shrink-0">
135
+ {key}:
136
+ </span>
137
+ {ov !== undefined && nv !== undefined ? (
138
+ <span>
139
+ <span className="text-destructive line-through mr-1">
140
+ {String(ov ?? "—")}
141
+ </span>
142
+ <span className="text-muted-foreground mx-0.5">→</span>
143
+ <span className="text-emerald-600 font-medium">
144
+ {String(nv ?? "—")}
145
+ </span>
146
+ </span>
147
+ ) : (
148
+ <span className="text-muted-foreground">
149
+ {String(nv ?? ov ?? "—")}
150
+ </span>
151
+ )}
152
+ </div>
153
+ );
154
+ })}
155
+ {changedKeys.length > 8 && (
156
+ <span className="text-xs text-muted-foreground">
157
+ +{changedKeys.length - 8} trường khác
158
+ </span>
159
+ )}
160
+ </div>
161
+ );
162
+ }
163
+
164
+ export interface SystemAuditPageProps {
165
+ /** Endpoint danh sách nhật ký. Mặc định /api/admin/system/audit. */
166
+ apiUrl?: string;
167
+ /** Hành động của app (nhãn + màu). Mặc định chỉ create/update/delete. */
168
+ actions?: AuditActionOption[];
169
+ /** Đối tượng của app. Rỗng thì ẩn hẳn bộ lọc đối tượng. */
170
+ resources?: AuditResourceOption[];
171
+ }
172
+
173
+ export function SystemAuditPage({
174
+ apiUrl = "/api/admin/system/audit",
175
+ actions = BASE_AUDIT_ACTIONS,
176
+ resources = [],
177
+ }: SystemAuditPageProps = {}) {
178
+ const [data, setData] = useState<AuditResponse | null>(null);
179
+ const [isLoading, setIsLoading] = useState(true);
180
+ const [page, setPage] = useState(0);
181
+ const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
182
+
183
+ // Bộ lọc — tất cả đẩy xuống server
184
+ const [search, setSearch] = useState("");
185
+ const [searchInput, setSearchInput] = useState(""); // đệm debounce
186
+ const [actionFilter, setActionFilter] = useState("all");
187
+ const [resourceFilter, setResourceFilter] = useState("all");
188
+ const [startDate, setStartDate] = useState("");
189
+ const [endDate, setEndDate] = useState("");
190
+
191
+ const actionMap = new Map(actions.map((a) => [a.value, a]));
192
+ const resourceMap = new Map(resources.map((r) => [r.value, r.label]));
193
+
194
+ const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
195
+ const handleSearchInput = (val: string) => {
196
+ setSearchInput(val);
197
+ if (searchTimer.current) clearTimeout(searchTimer.current);
198
+ searchTimer.current = setTimeout(() => {
199
+ setSearch(val);
200
+ setPage(0);
201
+ }, 400);
202
+ };
203
+
204
+ const fetchLogs = useCallback(async () => {
205
+ setIsLoading(true);
206
+ try {
207
+ const params = new URLSearchParams();
208
+ params.set("skip", String(page * PAGE_SIZE));
209
+ params.set("take", String(PAGE_SIZE));
210
+ if (search) params.set("search", search);
211
+ if (actionFilter !== "all") params.set("action", actionFilter);
212
+ if (resourceFilter !== "all") params.set("resource", resourceFilter);
213
+ if (startDate) params.set("startDate", new Date(startDate).toISOString());
214
+ if (endDate)
215
+ params.set("endDate", new Date(endDate + "T23:59:59").toISOString());
216
+
217
+ const res = await fetch(`${apiUrl}?${params}`);
218
+ if (res.ok) setData(await res.json());
219
+ } catch (e) {
220
+ console.error(e);
221
+ } finally {
222
+ setIsLoading(false);
223
+ }
224
+ }, [apiUrl, page, search, actionFilter, resourceFilter, startDate, endDate]);
225
+
226
+ useEffect(() => {
227
+ fetchLogs();
228
+ }, [fetchLogs]);
229
+
230
+ const clearFilters = () => {
231
+ setSearchInput("");
232
+ setSearch("");
233
+ setActionFilter("all");
234
+ setResourceFilter("all");
235
+ setStartDate("");
236
+ setEndDate("");
237
+ setPage(0);
238
+ };
239
+
240
+ const hasActiveFilters =
241
+ search ||
242
+ actionFilter !== "all" ||
243
+ resourceFilter !== "all" ||
244
+ startDate ||
245
+ endDate;
246
+
247
+ const totalPages = data ? Math.ceil(data.meta.total / PAGE_SIZE) : 0;
248
+
249
+ return (
250
+ <div className="p-4 sm:p-6 space-y-4 h-full flex flex-col">
251
+ {/* Header */}
252
+ <div className="flex items-center justify-between">
253
+ <div>
254
+ <h1 className="text-xl font-bold tracking-tight flex items-center gap-2">
255
+ <ScrollText className="h-5 w-5" />
256
+ Nhật ký hệ thống
257
+ </h1>
258
+ <p className="text-sm text-muted-foreground">
259
+ Theo dõi mọi thay đổi dữ liệu
260
+ {data && (
261
+ <span className="ml-1 text-xs">
262
+ ({data.meta.total.toLocaleString()} bản ghi)
263
+ </span>
264
+ )}
265
+ </p>
266
+ </div>
267
+ <Button
268
+ variant="outline"
269
+ size="sm"
270
+ onClick={fetchLogs}
271
+ disabled={isLoading}
272
+ >
273
+ <RefreshCw
274
+ className={`h-4 w-4 mr-1 ${isLoading ? "animate-spin" : ""}`}
275
+ />
276
+ Làm mới
277
+ </Button>
278
+ </div>
279
+
280
+ {/* Bộ lọc */}
281
+ <Card>
282
+ <CardHeader className="py-3">
283
+ <CardTitle className="text-sm font-medium flex items-center justify-between">
284
+ <span className="flex items-center gap-1.5">
285
+ <Filter className="h-3.5 w-3.5" />
286
+ Bộ lọc
287
+ </span>
288
+ {hasActiveFilters && (
289
+ <Button
290
+ variant="ghost"
291
+ size="sm"
292
+ className="h-6 text-xs"
293
+ onClick={clearFilters}
294
+ >
295
+ <X className="h-3 w-3 mr-1" /> Xóa bộ lọc
296
+ </Button>
297
+ )}
298
+ </CardTitle>
299
+ </CardHeader>
300
+ <CardContent className="pb-3">
301
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
302
+ <div className="relative sm:col-span-2 lg:col-span-1">
303
+ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
304
+ <Input
305
+ placeholder="Tìm theo mô tả, ID, resource..."
306
+ value={searchInput}
307
+ onChange={(e) => handleSearchInput(e.target.value)}
308
+ className="h-8 text-sm pl-8"
309
+ />
310
+ </div>
311
+
312
+ <Select
313
+ value={actionFilter}
314
+ onValueChange={(v) => {
315
+ setActionFilter(v);
316
+ setPage(0);
317
+ }}
318
+ >
319
+ <SelectTrigger className="h-8 text-sm">
320
+ <SelectValue placeholder="Tất cả hành động" />
321
+ </SelectTrigger>
322
+ <SelectContent>
323
+ <SelectItem value="all">Tất cả hành động</SelectItem>
324
+ {actions.map((a) => (
325
+ <SelectItem key={a.value} value={a.value}>
326
+ {a.label}
327
+ </SelectItem>
328
+ ))}
329
+ </SelectContent>
330
+ </Select>
331
+
332
+ {resources.length > 0 && (
333
+ <Select
334
+ value={resourceFilter}
335
+ onValueChange={(v) => {
336
+ setResourceFilter(v);
337
+ setPage(0);
338
+ }}
339
+ >
340
+ <SelectTrigger className="h-8 text-sm">
341
+ <SelectValue placeholder="Tất cả đối tượng" />
342
+ </SelectTrigger>
343
+ <SelectContent>
344
+ <SelectItem value="all">Tất cả đối tượng</SelectItem>
345
+ {resources.map((r) => (
346
+ <SelectItem key={r.value} value={r.value}>
347
+ {r.label}
348
+ </SelectItem>
349
+ ))}
350
+ </SelectContent>
351
+ </Select>
352
+ )}
353
+
354
+ <div className="flex items-center gap-2 sm:col-span-2 lg:col-span-1">
355
+ <CalendarDays className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
356
+ <Input
357
+ type="date"
358
+ value={startDate}
359
+ onChange={(e) => {
360
+ setStartDate(e.target.value);
361
+ setPage(0);
362
+ }}
363
+ className="h-8 text-xs flex-1"
364
+ />
365
+ <span className="text-muted-foreground text-xs">—</span>
366
+ <Input
367
+ type="date"
368
+ value={endDate}
369
+ onChange={(e) => {
370
+ setEndDate(e.target.value);
371
+ setPage(0);
372
+ }}
373
+ className="h-8 text-xs flex-1"
374
+ />
375
+ </div>
376
+ </div>
377
+ </CardContent>
378
+ </Card>
379
+
380
+ {/* Bảng */}
381
+ <Card className="flex-1 flex flex-col min-h-0">
382
+ <CardContent className="flex-1 overflow-auto p-0">
383
+ {isLoading ? (
384
+ <div className="flex items-center justify-center py-16">
385
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
386
+ <span className="ml-2 text-sm text-muted-foreground">
387
+ Đang tải nhật ký...
388
+ </span>
389
+ </div>
390
+ ) : (
391
+ <Table>
392
+ <TableHeader>
393
+ <TableRow>
394
+ <TableHead className="w-[150px]">Thời gian</TableHead>
395
+ <TableHead className="w-[130px]">Hành động</TableHead>
396
+ <TableHead className="w-[140px]">Đối tượng</TableHead>
397
+ <TableHead className="w-[140px]">Người thực hiện</TableHead>
398
+ <TableHead>Chi tiết</TableHead>
399
+ <TableHead className="w-[36px]" />
400
+ </TableRow>
401
+ </TableHeader>
402
+ <TableBody>
403
+ {(!data?.data || data.data.length === 0) && (
404
+ <TableRow>
405
+ <TableCell
406
+ colSpan={6}
407
+ className="text-center h-24 text-muted-foreground"
408
+ >
409
+ <ScrollText className="h-8 w-8 mx-auto mb-2 opacity-40" />
410
+ Không tìm thấy nhật ký nào
411
+ </TableCell>
412
+ </TableRow>
413
+ )}
414
+ {data?.data.map((log) => {
415
+ const actionCfg = actionMap.get(log.action);
416
+ const isExpanded = expandedIds.has(log.id);
417
+ const hasDetails =
418
+ (log.oldData && Object.keys(log.oldData).length > 0) ||
419
+ (log.newData && Object.keys(log.newData).length > 0);
420
+
421
+ // Tên người: quan hệ user trước, không có thì bóc tiền tố
422
+ // "[Tên]" mà logEntityAction/extension đã nhét vào mô tả.
423
+ let userName = "Hệ thống";
424
+ if (log.user?.name) userName = log.user.name;
425
+ else if (log.user?.email) userName = log.user.email;
426
+ else if (log.description) {
427
+ const m = log.description.match(/^\[(.*?)\]/);
428
+ if (m) userName = m[1];
429
+ }
430
+
431
+ const desc =
432
+ log.description?.replace(/^\[.*?\]\s*/, "") || "";
433
+
434
+ return (
435
+ <TableRow key={log.id} className="align-top">
436
+ <TableCell className="font-mono text-xs text-muted-foreground whitespace-nowrap">
437
+ {formatTime(log.createdAt)}
438
+ </TableCell>
439
+ <TableCell>
440
+ <Badge
441
+ className={`text-[10px] text-white ${actionCfg?.color || "bg-slate-400"}`}
442
+ >
443
+ {actionCfg?.label || log.action}
444
+ </Badge>
445
+ </TableCell>
446
+ <TableCell className="text-sm">
447
+ <div>
448
+ {resourceMap.get(log.resource) || log.resource}
449
+ </div>
450
+ {log.resourceId && (
451
+ <div
452
+ className="text-[10px] text-muted-foreground font-mono truncate max-w-[120px]"
453
+ title={log.resourceId}
454
+ >
455
+ {log.resourceId}
456
+ </div>
457
+ )}
458
+ </TableCell>
459
+ <TableCell className="text-sm">{userName}</TableCell>
460
+ <TableCell className="text-sm">
461
+ <div className="text-muted-foreground">{desc}</div>
462
+ {isExpanded && hasDetails && (
463
+ <DataChanges
464
+ oldData={log.oldData}
465
+ newData={log.newData}
466
+ />
467
+ )}
468
+ </TableCell>
469
+ <TableCell>
470
+ {hasDetails && (
471
+ <Button
472
+ variant="ghost"
473
+ size="sm"
474
+ className="h-6 w-6 p-0"
475
+ onClick={() =>
476
+ setExpandedIds((prev) => {
477
+ const next = new Set(prev);
478
+ if (next.has(log.id)) next.delete(log.id);
479
+ else next.add(log.id);
480
+ return next;
481
+ })
482
+ }
483
+ >
484
+ {isExpanded ? (
485
+ <ChevronUp className="h-3 w-3" />
486
+ ) : (
487
+ <ChevronDown className="h-3 w-3" />
488
+ )}
489
+ </Button>
490
+ )}
491
+ </TableCell>
492
+ </TableRow>
493
+ );
494
+ })}
495
+ </TableBody>
496
+ </Table>
497
+ )}
498
+ </CardContent>
499
+
500
+ {/* Phân trang */}
501
+ {totalPages > 1 && (
502
+ <div className="flex items-center justify-between px-4 py-2 border-t">
503
+ <span className="text-xs text-muted-foreground">
504
+ Trang {page + 1}/{totalPages} ·{" "}
505
+ {data?.meta.total.toLocaleString()} bản ghi
506
+ </span>
507
+ <div className="flex gap-1">
508
+ <Button
509
+ variant="outline"
510
+ size="sm"
511
+ className="h-7"
512
+ disabled={page === 0}
513
+ onClick={() => setPage((p) => p - 1)}
514
+ >
515
+ <ChevronLeft className="h-3 w-3" />
516
+ </Button>
517
+ <Button
518
+ variant="outline"
519
+ size="sm"
520
+ className="h-7"
521
+ disabled={!data?.meta.hasMore}
522
+ onClick={() => setPage((p) => p + 1)}
523
+ >
524
+ <ChevronRight className="h-3 w-3" />
525
+ </Button>
526
+ </div>
527
+ </div>
528
+ )}
529
+ </Card>
530
+ </div>
531
+ );
532
+ }