@goplusvn/core 0.1.51 → 0.1.52
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
CHANGED
|
@@ -29,5 +29,9 @@ GENERATED — đừng sửa tay) và tạo thư mục migration
|
|
|
29
29
|
## Feature hiện có
|
|
30
30
|
|
|
31
31
|
- `background-tasks` — bảng `background_tasks` cho trung tâm tác vụ nền
|
|
32
|
-
(export/import chạy nền). Runtime
|
|
33
|
-
|
|
32
|
+
(export/import chạy nền). Runtime: `@goerp/core/tasks`
|
|
33
|
+
(`configureTaskRunner`) + UI `@goerp/core/tasks/ui`.
|
|
34
|
+
- `error-logs` — bảng `error_logs` cho hệ ghi lỗi server
|
|
35
|
+
(`createErrorLogger`/`buildServerError` ở `errors/server-error`) + trang
|
|
36
|
+
admin `system/pages/error-logs-page` (app cung cấp API, mẫu vinhhoa
|
|
37
|
+
/api/error-logs).
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
-- goerp feature: error-logs — bước 0001 (idempotent). Bảng cho createErrorLogger
|
|
2
|
+
-- (@goerp/core/errors/server-error) + trang admin ErrorLogsPage.
|
|
3
|
+
-- gen_random_uuid() là hàm sẵn của PostgreSQL 13+.
|
|
4
|
+
CREATE TABLE IF NOT EXISTS "error_logs" (
|
|
5
|
+
"id" TEXT NOT NULL DEFAULT gen_random_uuid(),
|
|
6
|
+
"error_id" TEXT NOT NULL,
|
|
7
|
+
"fingerprint" TEXT,
|
|
8
|
+
"code" TEXT,
|
|
9
|
+
"message" TEXT NOT NULL,
|
|
10
|
+
"detail" TEXT,
|
|
11
|
+
"context" TEXT,
|
|
12
|
+
"module" TEXT,
|
|
13
|
+
"user_id" TEXT,
|
|
14
|
+
"url" TEXT,
|
|
15
|
+
"user_agent" TEXT,
|
|
16
|
+
"severity" TEXT NOT NULL DEFAULT 'error',
|
|
17
|
+
"resolved" BOOLEAN NOT NULL DEFAULT false,
|
|
18
|
+
"occurrence_count" INTEGER NOT NULL DEFAULT 1,
|
|
19
|
+
"last_seen_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
20
|
+
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
21
|
+
|
|
22
|
+
CONSTRAINT "error_logs_pkey" PRIMARY KEY ("id")
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
CREATE INDEX IF NOT EXISTS "idx_error_logs_code" ON "error_logs"("code");
|
|
26
|
+
CREATE INDEX IF NOT EXISTS "idx_error_logs_created_at" ON "error_logs"("created_at");
|
|
27
|
+
CREATE INDEX IF NOT EXISTS "idx_error_logs_error_id" ON "error_logs"("error_id");
|
|
28
|
+
CREATE INDEX IF NOT EXISTS "idx_error_logs_fingerprint" ON "error_logs"("fingerprint");
|
|
29
|
+
CREATE INDEX IF NOT EXISTS "idx_error_logs_module" ON "error_logs"("module");
|
|
30
|
+
CREATE INDEX IF NOT EXISTS "idx_error_logs_resolved" ON "error_logs"("resolved");
|
|
31
|
+
CREATE INDEX IF NOT EXISTS "idx_error_logs_severity" ON "error_logs"("severity");
|
|
32
|
+
CREATE INDEX IF NOT EXISTS "idx_error_logs_user_id" ON "error_logs"("user_id");
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
model ErrorLog {
|
|
2
|
+
id String @id @default(dbgenerated("gen_random_uuid()")) @map("id")
|
|
3
|
+
errorId String @map("error_id")
|
|
4
|
+
fingerprint String? @map("fingerprint")
|
|
5
|
+
code String? @map("code")
|
|
6
|
+
message String @map("message")
|
|
7
|
+
detail String? @map("detail")
|
|
8
|
+
context String? @map("context")
|
|
9
|
+
module String? @map("module")
|
|
10
|
+
userId String? @map("user_id")
|
|
11
|
+
url String? @map("url")
|
|
12
|
+
userAgent String? @map("user_agent")
|
|
13
|
+
severity String @default("error") @map("severity")
|
|
14
|
+
resolved Boolean @default(false) @map("resolved")
|
|
15
|
+
occurrenceCount Int @default(1) @map("occurrence_count")
|
|
16
|
+
lastSeenAt DateTime @default(now()) @map("last_seen_at") @db.Timestamptz(6)
|
|
17
|
+
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
|
18
|
+
|
|
19
|
+
@@index([code], map: "idx_error_logs_code")
|
|
20
|
+
@@index([createdAt], map: "idx_error_logs_created_at")
|
|
21
|
+
@@index([errorId], map: "idx_error_logs_error_id")
|
|
22
|
+
@@index([fingerprint], map: "idx_error_logs_fingerprint")
|
|
23
|
+
@@index([module], map: "idx_error_logs_module")
|
|
24
|
+
@@index([resolved], map: "idx_error_logs_resolved")
|
|
25
|
+
@@index([severity], map: "idx_error_logs_severity")
|
|
26
|
+
@@index([userId], map: "idx_error_logs_user_id")
|
|
27
|
+
@@map("error_logs")
|
|
28
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goplusvn/core",
|
|
3
3
|
"description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.52",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"registry": "https://registry.npmjs.org",
|
|
@@ -77,6 +77,7 @@
|
|
|
77
77
|
"./system/services/system-category-service": "./src/system/services/system-category-service.ts",
|
|
78
78
|
"./system/pages/system-settings-page": "./src/system/pages/system-settings-page.tsx",
|
|
79
79
|
"./system/pages/system-category-page": "./src/system/pages/system-category-page.tsx",
|
|
80
|
+
"./system/pages/error-logs-page": "./src/system/pages/error-logs-page.tsx",
|
|
80
81
|
"./rbac/role-service": "./src/rbac/role-service.ts",
|
|
81
82
|
"./rbac/resource-service": "./src/rbac/resource-service.ts",
|
|
82
83
|
"./infrastructure/cron/cron-manager": "./src/infrastructure/cron/cron-manager.ts",
|
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
// Trang admin "Nhật ký lỗi" dùng chung mọi app goerp (F1 batch 2) — đọc/ghi
|
|
4
|
+
// qua API app cung cấp (mẫu: vinhhoa /api/error-logs). Engine ghi lỗi + bảng
|
|
5
|
+
// error_logs ship sẵn: createErrorLogger (errors/server-error) + feature
|
|
6
|
+
// `error-logs` (goerp-features sync).
|
|
7
|
+
|
|
8
|
+
import { useCallback, useEffect, useState } from "react"
|
|
9
|
+
import { useDebounce } from "../../hooks"
|
|
10
|
+
import { toast } from "sonner"
|
|
11
|
+
import {
|
|
12
|
+
Badge,
|
|
13
|
+
Button,
|
|
14
|
+
Card,
|
|
15
|
+
CardContent,
|
|
16
|
+
Dialog,
|
|
17
|
+
DialogContent,
|
|
18
|
+
DialogHeader,
|
|
19
|
+
DialogTitle,
|
|
20
|
+
Input,
|
|
21
|
+
Select,
|
|
22
|
+
SelectContent,
|
|
23
|
+
SelectItem,
|
|
24
|
+
SelectTrigger,
|
|
25
|
+
SelectValue,
|
|
26
|
+
Table,
|
|
27
|
+
TableBody,
|
|
28
|
+
TableCell,
|
|
29
|
+
TableHead,
|
|
30
|
+
TableHeader,
|
|
31
|
+
TableRow,
|
|
32
|
+
} from "../../ui/primitives"
|
|
33
|
+
import {
|
|
34
|
+
AlertTriangle,
|
|
35
|
+
Bug,
|
|
36
|
+
CalendarDays,
|
|
37
|
+
Check,
|
|
38
|
+
CheckCircle2,
|
|
39
|
+
ChevronLeft,
|
|
40
|
+
ChevronRight,
|
|
41
|
+
Copy,
|
|
42
|
+
Filter,
|
|
43
|
+
Layers,
|
|
44
|
+
Link2,
|
|
45
|
+
RefreshCw,
|
|
46
|
+
Search,
|
|
47
|
+
Trash2,
|
|
48
|
+
User,
|
|
49
|
+
X,
|
|
50
|
+
} from "lucide-react"
|
|
51
|
+
|
|
52
|
+
interface ErrorLogItem {
|
|
53
|
+
id: string
|
|
54
|
+
errorId: string
|
|
55
|
+
code: string | null
|
|
56
|
+
message: string
|
|
57
|
+
detail: string | null
|
|
58
|
+
context: string | null
|
|
59
|
+
module: string | null
|
|
60
|
+
userId: string | null
|
|
61
|
+
userName?: string | null
|
|
62
|
+
url: string | null
|
|
63
|
+
severity: string
|
|
64
|
+
resolved: boolean
|
|
65
|
+
occurrenceCount: number
|
|
66
|
+
lastSeenAt: string
|
|
67
|
+
createdAt: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface ModuleStat { module: string; count: number }
|
|
71
|
+
|
|
72
|
+
interface Meta {
|
|
73
|
+
total: number; skip: number; take: number; hasMore: boolean
|
|
74
|
+
todayCount: number; unresolvedCount: number
|
|
75
|
+
moduleStats: ModuleStat[]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Parse stack trace string into structured frames
|
|
79
|
+
function parseStackTrace(detail: string): { frame: string; isApp: boolean }[] {
|
|
80
|
+
return detail
|
|
81
|
+
.split("\n")
|
|
82
|
+
.map((line) => line.trim())
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
.map((line) => ({
|
|
85
|
+
frame: line,
|
|
86
|
+
isApp: line.includes("src/") || line.includes("app/") || line.includes("pages/"),
|
|
87
|
+
}))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function StackTraceViewer({ detail }: { detail: string }) {
|
|
91
|
+
const frames = parseStackTrace(detail)
|
|
92
|
+
// Try to parse as JSON first (structured error)
|
|
93
|
+
try {
|
|
94
|
+
const json = JSON.parse(detail)
|
|
95
|
+
return (
|
|
96
|
+
<pre className="text-xs bg-muted rounded-md p-3 overflow-x-auto whitespace-pre-wrap max-h-[240px] overflow-y-auto">
|
|
97
|
+
{JSON.stringify(json, null, 2)}
|
|
98
|
+
</pre>
|
|
99
|
+
)
|
|
100
|
+
} catch {
|
|
101
|
+
// Not JSON — render as stack trace
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div className="rounded-md bg-slate-950 text-slate-100 text-xs font-mono p-3 max-h-[240px] overflow-y-auto space-y-0.5">
|
|
106
|
+
{frames.map((f, i) => (
|
|
107
|
+
<div
|
|
108
|
+
key={i}
|
|
109
|
+
className={`leading-5 ${
|
|
110
|
+
i === 0 ? "text-red-400 font-bold" :
|
|
111
|
+
f.isApp ? "text-amber-300" : "text-slate-400"
|
|
112
|
+
}`}
|
|
113
|
+
>
|
|
114
|
+
{f.frame}
|
|
115
|
+
</div>
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function SeverityBadge({ sev }: { sev: string }) {
|
|
122
|
+
const map: Record<string, string> = {
|
|
123
|
+
critical: "bg-danger text-white",
|
|
124
|
+
error: "bg-danger-subtle text-danger-text",
|
|
125
|
+
warning: "bg-warning-subtle text-warning-text",
|
|
126
|
+
}
|
|
127
|
+
return (
|
|
128
|
+
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${map[sev] || "bg-muted text-muted-foreground"}`}>
|
|
129
|
+
{sev}
|
|
130
|
+
</span>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function ErrorLogsPage({
|
|
135
|
+
initialSearch,
|
|
136
|
+
apiUrl = "/api/error-logs",
|
|
137
|
+
}: {
|
|
138
|
+
/** Deep-link từ dashboard vận hành: ?search=<errorId> */
|
|
139
|
+
initialSearch?: string
|
|
140
|
+
/** Endpoint app cung cấp (GET list/DELETE purge + [id] PATCH resolve). */
|
|
141
|
+
apiUrl?: string
|
|
142
|
+
}) {
|
|
143
|
+
const [logs, setLogs] = useState<ErrorLogItem[]>([])
|
|
144
|
+
const [meta, setMeta] = useState<Meta>({
|
|
145
|
+
total: 0, skip: 0, take: 30, hasMore: false,
|
|
146
|
+
todayCount: 0, unresolvedCount: 0, moduleStats: [],
|
|
147
|
+
})
|
|
148
|
+
const [loading, setLoading] = useState(true)
|
|
149
|
+
const [search, setSearch] = useState(initialSearch ?? "")
|
|
150
|
+
const [severity, setSeverity] = useState("all")
|
|
151
|
+
// Khi deep-link theo errorId thì không lọc resolved — link luôn tìm ra dòng
|
|
152
|
+
const [resolved, setResolved] = useState(initialSearch ? "all" : "false")
|
|
153
|
+
const [moduleFilter, setModuleFilter] = useState("all")
|
|
154
|
+
const [startDate, setStartDate] = useState("")
|
|
155
|
+
const [endDate, setEndDate] = useState("")
|
|
156
|
+
const [selectedLog, setSelectedLog] = useState<ErrorLogItem | null>(null)
|
|
157
|
+
const [page, setPage] = useState(0)
|
|
158
|
+
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
|
159
|
+
const [deleting, setDeleting] = useState(false)
|
|
160
|
+
const [showFilters, setShowFilters] = useState(false)
|
|
161
|
+
|
|
162
|
+
const PAGE_SIZE = 30
|
|
163
|
+
|
|
164
|
+
const debouncedSearch = useDebounce(search, 350)
|
|
165
|
+
|
|
166
|
+
const fetchLogs = useCallback(async (signal?: AbortSignal) => {
|
|
167
|
+
setLoading(true)
|
|
168
|
+
try {
|
|
169
|
+
const params = new URLSearchParams()
|
|
170
|
+
if (debouncedSearch) params.set("search", debouncedSearch)
|
|
171
|
+
if (severity !== "all") params.set("severity", severity)
|
|
172
|
+
if (resolved !== "all") params.set("resolved", resolved)
|
|
173
|
+
if (moduleFilter !== "all") params.set("module", moduleFilter)
|
|
174
|
+
if (startDate) params.set("startDate", startDate)
|
|
175
|
+
if (endDate) params.set("endDate", new Date(endDate + "T23:59:59").toISOString())
|
|
176
|
+
params.set("skip", String(page * PAGE_SIZE))
|
|
177
|
+
params.set("take", String(PAGE_SIZE))
|
|
178
|
+
|
|
179
|
+
const res = await fetch(`${apiUrl}?${params}`, { signal })
|
|
180
|
+
if (!res.ok) return
|
|
181
|
+
const data = await res.json()
|
|
182
|
+
if (data.data) {
|
|
183
|
+
setLogs(data.data)
|
|
184
|
+
setMeta(data.meta)
|
|
185
|
+
}
|
|
186
|
+
} catch (e) {
|
|
187
|
+
if ((e as any)?.name === "AbortError") return
|
|
188
|
+
/* silent */
|
|
189
|
+
} finally {
|
|
190
|
+
setLoading(false)
|
|
191
|
+
}
|
|
192
|
+
}, [debouncedSearch, severity, resolved, moduleFilter, startDate, endDate, page])
|
|
193
|
+
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
const controller = new AbortController()
|
|
196
|
+
fetchLogs(controller.signal)
|
|
197
|
+
return () => controller.abort()
|
|
198
|
+
}, [fetchLogs])
|
|
199
|
+
|
|
200
|
+
const handleToggleResolved = async (log: ErrorLogItem) => {
|
|
201
|
+
try {
|
|
202
|
+
await fetch(`${apiUrl}/${log.id}`, {
|
|
203
|
+
method: "PATCH",
|
|
204
|
+
headers: { "Content-Type": "application/json" },
|
|
205
|
+
body: JSON.stringify({ resolved: !log.resolved }),
|
|
206
|
+
})
|
|
207
|
+
toast.success(log.resolved ? "Đánh dấu chưa xử lý" : "Đánh dấu đã xử lý")
|
|
208
|
+
fetchLogs()
|
|
209
|
+
if (selectedLog?.id === log.id) {
|
|
210
|
+
setSelectedLog({ ...log, resolved: !log.resolved })
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
toast.error("Lỗi cập nhật trạng thái")
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const handleDeleteLogs = async (mode: "older_than" | "resolved", days?: number) => {
|
|
218
|
+
setDeleting(true)
|
|
219
|
+
try {
|
|
220
|
+
const params = new URLSearchParams({ mode })
|
|
221
|
+
if (days) params.set("days", String(days))
|
|
222
|
+
const res = await fetch(`${apiUrl}?${params}`, { method: "DELETE" })
|
|
223
|
+
const data = await res.json()
|
|
224
|
+
if (data.success) {
|
|
225
|
+
toast.success(`Đã xóa ${data.deleted} bản ghi`)
|
|
226
|
+
setShowDeleteDialog(false)
|
|
227
|
+
fetchLogs()
|
|
228
|
+
} else {
|
|
229
|
+
toast.error("Xóa log thất bại")
|
|
230
|
+
}
|
|
231
|
+
} catch {
|
|
232
|
+
toast.error("Xóa log thất bại")
|
|
233
|
+
} finally {
|
|
234
|
+
setDeleting(false)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const formatDate = (s: string) =>
|
|
239
|
+
new Date(s).toLocaleString("vi-VN", {
|
|
240
|
+
timeZone: "Asia/Ho_Chi_Minh",
|
|
241
|
+
day: "2-digit", month: "2-digit", year: "numeric",
|
|
242
|
+
hour: "2-digit", minute: "2-digit",
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
const hasActiveFilters = search || severity !== "all" || resolved !== "all" ||
|
|
246
|
+
moduleFilter !== "all" || startDate || endDate
|
|
247
|
+
|
|
248
|
+
const clearFilters = () => {
|
|
249
|
+
setSearch(""); setSeverity("all"); setResolved("false")
|
|
250
|
+
setModuleFilter("all"); setStartDate(""); setEndDate(""); setPage(0)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const allModules = Array.from(
|
|
254
|
+
new Set([
|
|
255
|
+
...meta.moduleStats.map((m) => m.module),
|
|
256
|
+
...logs.map((l) => l.module).filter(Boolean),
|
|
257
|
+
])
|
|
258
|
+
).filter(Boolean) as string[]
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<div className="flex h-full flex-col space-y-4 p-4 md:p-6">
|
|
262
|
+
{/* Header */}
|
|
263
|
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
|
264
|
+
<div>
|
|
265
|
+
<h2 className="text-xl md:text-2xl font-bold tracking-tight flex items-center gap-2">
|
|
266
|
+
<Bug className="h-5 w-5 md:h-6 md:w-6" />
|
|
267
|
+
Nhật ký lỗi
|
|
268
|
+
</h2>
|
|
269
|
+
<p className="text-muted-foreground text-sm hidden sm:block">
|
|
270
|
+
Theo dõi và quản lý lỗi hệ thống
|
|
271
|
+
</p>
|
|
272
|
+
</div>
|
|
273
|
+
<div className="flex gap-2">
|
|
274
|
+
<Button
|
|
275
|
+
variant={showFilters ? "default" : "outline"}
|
|
276
|
+
size="sm"
|
|
277
|
+
onClick={() => setShowFilters((v) => !v)}
|
|
278
|
+
>
|
|
279
|
+
<Filter className="mr-1.5 h-4 w-4" />
|
|
280
|
+
Bộ lọc
|
|
281
|
+
{hasActiveFilters && (
|
|
282
|
+
<span className="ml-1.5 rounded-full bg-primary-foreground text-primary w-4 h-4 text-[10px] flex items-center justify-center font-bold">
|
|
283
|
+
!
|
|
284
|
+
</span>
|
|
285
|
+
)}
|
|
286
|
+
</Button>
|
|
287
|
+
<Button variant="outline" size="sm" onClick={() => setShowDeleteDialog(true)}>
|
|
288
|
+
<Trash2 className="mr-1.5 h-4 w-4" />
|
|
289
|
+
Xóa cũ
|
|
290
|
+
</Button>
|
|
291
|
+
<Button variant="outline" size="sm" onClick={() => fetchLogs()} disabled={loading}>
|
|
292
|
+
<RefreshCw className={`mr-1.5 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
|
293
|
+
Làm mới
|
|
294
|
+
</Button>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
|
|
298
|
+
{/* Stats bar */}
|
|
299
|
+
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
300
|
+
<Card>
|
|
301
|
+
<CardContent className="p-3 text-center">
|
|
302
|
+
<p className="text-2xl font-bold text-destructive">{meta.unresolvedCount}</p>
|
|
303
|
+
<p className="text-xs text-muted-foreground mt-0.5">Chưa xử lý</p>
|
|
304
|
+
</CardContent>
|
|
305
|
+
</Card>
|
|
306
|
+
<Card>
|
|
307
|
+
<CardContent className="p-3 text-center">
|
|
308
|
+
<p className="text-2xl font-bold">{meta.todayCount}</p>
|
|
309
|
+
<p className="text-xs text-muted-foreground mt-0.5">Hôm nay</p>
|
|
310
|
+
</CardContent>
|
|
311
|
+
</Card>
|
|
312
|
+
{meta.moduleStats.slice(0, 2).map((m) => (
|
|
313
|
+
<Card key={m.module}>
|
|
314
|
+
<CardContent className="p-3 text-center">
|
|
315
|
+
<p className="text-2xl font-bold text-amber-600">{m.count}</p>
|
|
316
|
+
<p className="text-xs text-muted-foreground mt-0.5 truncate">{m.module}</p>
|
|
317
|
+
</CardContent>
|
|
318
|
+
</Card>
|
|
319
|
+
))}
|
|
320
|
+
</div>
|
|
321
|
+
|
|
322
|
+
{/* Filters (collapsible) */}
|
|
323
|
+
{showFilters && (
|
|
324
|
+
<Card>
|
|
325
|
+
<CardContent className="p-3">
|
|
326
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
327
|
+
<div className="relative sm:col-span-2 lg:col-span-1">
|
|
328
|
+
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
|
329
|
+
<Input
|
|
330
|
+
placeholder="Tìm message, errorId, context..."
|
|
331
|
+
value={search}
|
|
332
|
+
onChange={(e) => { setSearch(e.target.value); setPage(0) }}
|
|
333
|
+
className="pl-8"
|
|
334
|
+
/>
|
|
335
|
+
</div>
|
|
336
|
+
<Select value={severity} onValueChange={(v) => { setSeverity(v); setPage(0) }}>
|
|
337
|
+
<SelectTrigger>
|
|
338
|
+
<AlertTriangle className="h-3.5 w-3.5 mr-1.5 text-muted-foreground" />
|
|
339
|
+
<SelectValue placeholder="Mức độ" />
|
|
340
|
+
</SelectTrigger>
|
|
341
|
+
<SelectContent>
|
|
342
|
+
<SelectItem value="all">Tất cả mức độ</SelectItem>
|
|
343
|
+
<SelectItem value="critical">Critical</SelectItem>
|
|
344
|
+
<SelectItem value="error">Error</SelectItem>
|
|
345
|
+
<SelectItem value="warning">Warning</SelectItem>
|
|
346
|
+
</SelectContent>
|
|
347
|
+
</Select>
|
|
348
|
+
<Select value={resolved} onValueChange={(v) => { setResolved(v); setPage(0) }}>
|
|
349
|
+
<SelectTrigger>
|
|
350
|
+
<SelectValue placeholder="Trạng thái" />
|
|
351
|
+
</SelectTrigger>
|
|
352
|
+
<SelectContent>
|
|
353
|
+
<SelectItem value="all">Tất cả</SelectItem>
|
|
354
|
+
<SelectItem value="false">Chưa xử lý</SelectItem>
|
|
355
|
+
<SelectItem value="true">Đã xử lý</SelectItem>
|
|
356
|
+
</SelectContent>
|
|
357
|
+
</Select>
|
|
358
|
+
<Select value={moduleFilter} onValueChange={(v) => { setModuleFilter(v); setPage(0) }}>
|
|
359
|
+
<SelectTrigger>
|
|
360
|
+
<Layers className="h-3.5 w-3.5 mr-1.5 text-muted-foreground" />
|
|
361
|
+
<SelectValue placeholder="Module" />
|
|
362
|
+
</SelectTrigger>
|
|
363
|
+
<SelectContent>
|
|
364
|
+
<SelectItem value="all">Tất cả module</SelectItem>
|
|
365
|
+
{allModules.map((m) => (
|
|
366
|
+
<SelectItem key={m} value={m}>{m}</SelectItem>
|
|
367
|
+
))}
|
|
368
|
+
</SelectContent>
|
|
369
|
+
</Select>
|
|
370
|
+
<div className="flex items-center gap-2 sm:col-span-2 lg:col-span-1">
|
|
371
|
+
<CalendarDays className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
372
|
+
<Input
|
|
373
|
+
type="date" value={startDate}
|
|
374
|
+
onChange={(e) => { setStartDate(e.target.value); setPage(0) }}
|
|
375
|
+
className="flex-1"
|
|
376
|
+
/>
|
|
377
|
+
<span className="text-muted-foreground text-sm">→</span>
|
|
378
|
+
<Input
|
|
379
|
+
type="date" value={endDate}
|
|
380
|
+
onChange={(e) => { setEndDate(e.target.value); setPage(0) }}
|
|
381
|
+
className="flex-1"
|
|
382
|
+
/>
|
|
383
|
+
</div>
|
|
384
|
+
{hasActiveFilters && (
|
|
385
|
+
<Button variant="ghost" size="sm" className="w-fit" onClick={clearFilters}>
|
|
386
|
+
<X className="h-3.5 w-3.5 mr-1" /> Xóa bộ lọc
|
|
387
|
+
</Button>
|
|
388
|
+
)}
|
|
389
|
+
</div>
|
|
390
|
+
</CardContent>
|
|
391
|
+
</Card>
|
|
392
|
+
)}
|
|
393
|
+
|
|
394
|
+
{/* Table */}
|
|
395
|
+
<Card className="flex-1">
|
|
396
|
+
<CardContent className="p-0">
|
|
397
|
+
<div className="rounded-md border overflow-x-auto">
|
|
398
|
+
<Table>
|
|
399
|
+
<TableHeader>
|
|
400
|
+
<TableRow>
|
|
401
|
+
<TableHead className="w-[130px]">Thời gian</TableHead>
|
|
402
|
+
<TableHead className="w-[85px]">Mức độ</TableHead>
|
|
403
|
+
<TableHead className="w-[100px] hidden md:table-cell">Module</TableHead>
|
|
404
|
+
<TableHead>Thông báo lỗi</TableHead>
|
|
405
|
+
<TableHead className="w-[60px] text-center">Lần</TableHead>
|
|
406
|
+
<TableHead className="w-[80px]">Trạng thái</TableHead>
|
|
407
|
+
<TableHead className="w-[50px] text-right hidden sm:table-cell" />
|
|
408
|
+
</TableRow>
|
|
409
|
+
</TableHeader>
|
|
410
|
+
<TableBody>
|
|
411
|
+
{loading ? (
|
|
412
|
+
<TableRow>
|
|
413
|
+
<TableCell colSpan={7} className="text-center py-8">
|
|
414
|
+
<RefreshCw className="h-5 w-5 animate-spin mx-auto text-muted-foreground" />
|
|
415
|
+
</TableCell>
|
|
416
|
+
</TableRow>
|
|
417
|
+
) : logs.length === 0 ? (
|
|
418
|
+
<TableRow>
|
|
419
|
+
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
|
420
|
+
<CheckCircle2 className="h-8 w-8 mx-auto mb-2 text-green-500" />
|
|
421
|
+
Không có lỗi nào
|
|
422
|
+
</TableCell>
|
|
423
|
+
</TableRow>
|
|
424
|
+
) : (
|
|
425
|
+
logs.map((log) => (
|
|
426
|
+
<TableRow
|
|
427
|
+
key={log.id}
|
|
428
|
+
className="cursor-pointer hover:bg-muted/50"
|
|
429
|
+
onClick={() => setSelectedLog(log)}
|
|
430
|
+
>
|
|
431
|
+
<TableCell className="text-xs text-muted-foreground font-mono">
|
|
432
|
+
{formatDate(log.lastSeenAt || log.createdAt)}
|
|
433
|
+
</TableCell>
|
|
434
|
+
<TableCell>
|
|
435
|
+
<SeverityBadge sev={log.severity} />
|
|
436
|
+
</TableCell>
|
|
437
|
+
<TableCell className="hidden md:table-cell">
|
|
438
|
+
{log.module ? (
|
|
439
|
+
<span className="text-xs bg-muted text-foreground px-1.5 py-0.5 rounded font-mono">
|
|
440
|
+
{log.module}
|
|
441
|
+
</span>
|
|
442
|
+
) : (
|
|
443
|
+
<span className="text-xs text-muted-foreground">—</span>
|
|
444
|
+
)}
|
|
445
|
+
</TableCell>
|
|
446
|
+
<TableCell
|
|
447
|
+
className="max-w-[200px] md:max-w-[300px] truncate text-sm"
|
|
448
|
+
title={log.message}
|
|
449
|
+
>
|
|
450
|
+
{log.message}
|
|
451
|
+
</TableCell>
|
|
452
|
+
<TableCell className="text-center">
|
|
453
|
+
{log.occurrenceCount > 1 ? (
|
|
454
|
+
<span className="text-xs font-bold text-amber-600 bg-amber-50 rounded-full px-1.5 py-0.5">
|
|
455
|
+
×{log.occurrenceCount}
|
|
456
|
+
</span>
|
|
457
|
+
) : (
|
|
458
|
+
<span className="text-xs text-muted-foreground">1</span>
|
|
459
|
+
)}
|
|
460
|
+
</TableCell>
|
|
461
|
+
<TableCell>
|
|
462
|
+
{log.resolved ? (
|
|
463
|
+
<Badge variant="secondary" className="bg-success-subtle text-success-text text-xs">
|
|
464
|
+
Đã xử lý
|
|
465
|
+
</Badge>
|
|
466
|
+
) : (
|
|
467
|
+
<Badge variant="outline" className="text-destructive text-xs">
|
|
468
|
+
Chưa xử lý
|
|
469
|
+
</Badge>
|
|
470
|
+
)}
|
|
471
|
+
</TableCell>
|
|
472
|
+
<TableCell className="text-right hidden sm:table-cell">
|
|
473
|
+
<Button
|
|
474
|
+
variant="ghost" size="sm"
|
|
475
|
+
onClick={(e) => { e.stopPropagation(); handleToggleResolved(log) }}
|
|
476
|
+
title={log.resolved ? "Đánh dấu chưa xử lý" : "Đánh dấu đã xử lý"}
|
|
477
|
+
>
|
|
478
|
+
<Check className="h-4 w-4" />
|
|
479
|
+
</Button>
|
|
480
|
+
</TableCell>
|
|
481
|
+
</TableRow>
|
|
482
|
+
))
|
|
483
|
+
)}
|
|
484
|
+
</TableBody>
|
|
485
|
+
</Table>
|
|
486
|
+
</div>
|
|
487
|
+
</CardContent>
|
|
488
|
+
</Card>
|
|
489
|
+
|
|
490
|
+
{/* Pagination */}
|
|
491
|
+
<div className="flex items-center justify-between text-sm">
|
|
492
|
+
<p className="text-muted-foreground">
|
|
493
|
+
{logs.length} / {meta.total} bản ghi
|
|
494
|
+
</p>
|
|
495
|
+
<div className="flex gap-2">
|
|
496
|
+
<Button variant="outline" size="sm" disabled={page === 0}
|
|
497
|
+
onClick={() => setPage((p) => p - 1)}>
|
|
498
|
+
<ChevronLeft className="h-4 w-4" />
|
|
499
|
+
</Button>
|
|
500
|
+
<Button variant="outline" size="sm" disabled={!meta.hasMore}
|
|
501
|
+
onClick={() => setPage((p) => p + 1)}>
|
|
502
|
+
<ChevronRight className="h-4 w-4" />
|
|
503
|
+
</Button>
|
|
504
|
+
</div>
|
|
505
|
+
</div>
|
|
506
|
+
|
|
507
|
+
{/* Detail Dialog */}
|
|
508
|
+
<Dialog open={!!selectedLog} onOpenChange={() => setSelectedLog(null)}>
|
|
509
|
+
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
|
510
|
+
<DialogHeader>
|
|
511
|
+
<DialogTitle className="flex items-center gap-2">
|
|
512
|
+
<Bug className="h-5 w-5" />
|
|
513
|
+
Chi tiết lỗi
|
|
514
|
+
</DialogTitle>
|
|
515
|
+
</DialogHeader>
|
|
516
|
+
{selectedLog && (
|
|
517
|
+
<div className="space-y-4">
|
|
518
|
+
{/* Meta grid */}
|
|
519
|
+
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm">
|
|
520
|
+
<div>
|
|
521
|
+
<p className="text-muted-foreground text-xs mb-1">Mức độ</p>
|
|
522
|
+
<SeverityBadge sev={selectedLog.severity} />
|
|
523
|
+
</div>
|
|
524
|
+
<div>
|
|
525
|
+
<p className="text-muted-foreground text-xs mb-1">Số lần</p>
|
|
526
|
+
<p className="font-bold text-amber-600">
|
|
527
|
+
×{selectedLog.occurrenceCount}
|
|
528
|
+
{selectedLog.occurrenceCount > 1 && (
|
|
529
|
+
<span className="text-xs text-muted-foreground font-normal ml-1">lần</span>
|
|
530
|
+
)}
|
|
531
|
+
</p>
|
|
532
|
+
</div>
|
|
533
|
+
<div>
|
|
534
|
+
<p className="text-muted-foreground text-xs mb-1">Module</p>
|
|
535
|
+
<p className="font-mono text-xs">{selectedLog.module || "—"}</p>
|
|
536
|
+
</div>
|
|
537
|
+
<div>
|
|
538
|
+
<p className="text-muted-foreground text-xs mb-1">Lần đầu</p>
|
|
539
|
+
<p className="text-xs">{formatDate(selectedLog.createdAt)}</p>
|
|
540
|
+
</div>
|
|
541
|
+
<div>
|
|
542
|
+
<p className="text-muted-foreground text-xs mb-1">Lần cuối</p>
|
|
543
|
+
<p className="text-xs">{formatDate(selectedLog.lastSeenAt)}</p>
|
|
544
|
+
</div>
|
|
545
|
+
<div>
|
|
546
|
+
<p className="text-muted-foreground text-xs mb-1">Error ID</p>
|
|
547
|
+
<p className="font-mono text-xs">{selectedLog.errorId}</p>
|
|
548
|
+
</div>
|
|
549
|
+
{selectedLog.code && (
|
|
550
|
+
<div>
|
|
551
|
+
<p className="text-muted-foreground text-xs mb-1">Code</p>
|
|
552
|
+
<p className="font-mono text-xs">{selectedLog.code}</p>
|
|
553
|
+
</div>
|
|
554
|
+
)}
|
|
555
|
+
{selectedLog.context && (
|
|
556
|
+
<div className="col-span-2">
|
|
557
|
+
<p className="text-muted-foreground text-xs mb-1">Context</p>
|
|
558
|
+
<p className="font-mono text-xs">{selectedLog.context}</p>
|
|
559
|
+
</div>
|
|
560
|
+
)}
|
|
561
|
+
{selectedLog.url && (
|
|
562
|
+
<div className="col-span-2 sm:col-span-3">
|
|
563
|
+
<p className="text-muted-foreground text-xs mb-1 flex items-center gap-1">
|
|
564
|
+
<Link2 className="h-3 w-3" /> URL
|
|
565
|
+
</p>
|
|
566
|
+
<p className="text-xs font-mono break-all">{selectedLog.url}</p>
|
|
567
|
+
</div>
|
|
568
|
+
)}
|
|
569
|
+
{selectedLog.userId && (
|
|
570
|
+
<div>
|
|
571
|
+
<p className="text-muted-foreground text-xs mb-1 flex items-center gap-1">
|
|
572
|
+
<User className="h-3 w-3" /> Người dùng
|
|
573
|
+
</p>
|
|
574
|
+
<p className="font-mono text-xs">{selectedLog.userName || selectedLog.userId}</p>
|
|
575
|
+
</div>
|
|
576
|
+
)}
|
|
577
|
+
</div>
|
|
578
|
+
|
|
579
|
+
{/* Message */}
|
|
580
|
+
<div>
|
|
581
|
+
<p className="text-sm text-muted-foreground mb-1">Thông báo lỗi</p>
|
|
582
|
+
<p className="font-medium">{selectedLog.message}</p>
|
|
583
|
+
</div>
|
|
584
|
+
|
|
585
|
+
{/* Stack trace */}
|
|
586
|
+
{selectedLog.detail && (
|
|
587
|
+
<div>
|
|
588
|
+
<div className="flex items-center justify-between mb-1">
|
|
589
|
+
<p className="text-sm text-muted-foreground">Chi tiết kỹ thuật</p>
|
|
590
|
+
<Button
|
|
591
|
+
variant="ghost" size="sm"
|
|
592
|
+
onClick={() => {
|
|
593
|
+
navigator.clipboard.writeText(selectedLog.detail!)
|
|
594
|
+
toast.success("Đã copy")
|
|
595
|
+
}}
|
|
596
|
+
>
|
|
597
|
+
<Copy className="h-3 w-3 mr-1" /> Copy
|
|
598
|
+
</Button>
|
|
599
|
+
</div>
|
|
600
|
+
<StackTraceViewer detail={selectedLog.detail} />
|
|
601
|
+
</div>
|
|
602
|
+
)}
|
|
603
|
+
|
|
604
|
+
{/* Actions */}
|
|
605
|
+
<div className="flex justify-between pt-2 border-t">
|
|
606
|
+
<Button
|
|
607
|
+
variant={selectedLog.resolved ? "outline" : "default"}
|
|
608
|
+
size="sm"
|
|
609
|
+
onClick={() => handleToggleResolved(selectedLog)}
|
|
610
|
+
>
|
|
611
|
+
{selectedLog.resolved ? "Đánh dấu chưa xử lý" : "Đánh dấu đã xử lý"}
|
|
612
|
+
</Button>
|
|
613
|
+
</div>
|
|
614
|
+
</div>
|
|
615
|
+
)}
|
|
616
|
+
</DialogContent>
|
|
617
|
+
</Dialog>
|
|
618
|
+
|
|
619
|
+
{/* Delete Dialog */}
|
|
620
|
+
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
|
621
|
+
<DialogContent className="max-w-sm">
|
|
622
|
+
<DialogHeader>
|
|
623
|
+
<DialogTitle className="flex items-center gap-2">
|
|
624
|
+
<Trash2 className="h-5 w-5 text-destructive" />
|
|
625
|
+
Xóa log lỗi
|
|
626
|
+
</DialogTitle>
|
|
627
|
+
</DialogHeader>
|
|
628
|
+
<div className="grid gap-2">
|
|
629
|
+
{[7, 30, 90].map((d) => (
|
|
630
|
+
<Button
|
|
631
|
+
key={d} variant="outline" size="sm" className="justify-start"
|
|
632
|
+
disabled={deleting}
|
|
633
|
+
onClick={() => handleDeleteLogs("older_than", d)}
|
|
634
|
+
>
|
|
635
|
+
Xóa log cũ hơn {d} ngày
|
|
636
|
+
</Button>
|
|
637
|
+
))}
|
|
638
|
+
<Button
|
|
639
|
+
variant="outline" size="sm"
|
|
640
|
+
className="justify-start text-green-700"
|
|
641
|
+
disabled={deleting}
|
|
642
|
+
onClick={() => handleDeleteLogs("resolved")}
|
|
643
|
+
>
|
|
644
|
+
<CheckCircle2 className="mr-2 h-4 w-4" />
|
|
645
|
+
Xóa tất cả log đã xử lý
|
|
646
|
+
</Button>
|
|
647
|
+
</div>
|
|
648
|
+
</DialogContent>
|
|
649
|
+
</Dialog>
|
|
650
|
+
</div>
|
|
651
|
+
)
|
|
652
|
+
}
|