dev3000 0.0.49 → 0.0.51
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/README.md +55 -6
- package/dist/cdp-monitor.d.ts.map +1 -1
- package/dist/cdp-monitor.js +54 -48
- package/dist/cdp-monitor.js.map +1 -1
- package/dist/cli.js +39 -33
- package/dist/cli.js.map +1 -1
- package/dist/dev-environment.d.ts +2 -0
- package/dist/dev-environment.d.ts.map +1 -1
- package/dist/dev-environment.js +212 -181
- package/dist/dev-environment.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/mcp-server/app/api/config/route.ts +7 -7
- package/mcp-server/app/api/logs/append/route.ts +59 -51
- package/mcp-server/app/api/logs/head/route.ts +22 -22
- package/mcp-server/app/api/logs/list/route.ts +39 -42
- package/mcp-server/app/api/logs/rotate/route.ts +28 -38
- package/mcp-server/app/api/logs/stream/route.ts +35 -35
- package/mcp-server/app/api/logs/tail/route.ts +22 -22
- package/mcp-server/app/api/mcp/[transport]/route.ts +189 -188
- package/mcp-server/app/api/replay/route.ts +217 -202
- package/mcp-server/app/layout.tsx +9 -8
- package/mcp-server/app/logs/LogsClient.test.ts +123 -99
- package/mcp-server/app/logs/LogsClient.tsx +724 -562
- package/mcp-server/app/logs/page.tsx +71 -72
- package/mcp-server/app/logs/utils.ts +99 -28
- package/mcp-server/app/page.tsx +10 -14
- package/mcp-server/app/replay/ReplayClient.tsx +120 -119
- package/mcp-server/app/replay/page.tsx +3 -3
- package/mcp-server/next.config.ts +2 -0
- package/mcp-server/package.json +5 -2
- package/mcp-server/pnpm-lock.yaml +37 -5
- package/mcp-server/tsconfig.json +4 -17
- package/package.json +16 -13
|
@@ -1,87 +1,97 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
import
|
|
4
|
-
import { useRouter, useSearchParams } from "next/navigation"
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import Image from "next/image"
|
|
4
|
+
import { useRouter, useSearchParams } from "next/navigation"
|
|
5
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
6
|
+
import type { LogEntry, LogFile, LogListResponse, LogsApiResponse } from "@/types"
|
|
7
|
+
|
|
8
|
+
// Define interfaces for object property rendering
|
|
9
|
+
interface PropertyData {
|
|
10
|
+
name: string
|
|
11
|
+
value: string | number | boolean | null
|
|
12
|
+
type: string
|
|
13
|
+
subtype?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ReplayEvent {
|
|
17
|
+
timestamp: string
|
|
18
|
+
event: string
|
|
19
|
+
details: string
|
|
20
|
+
type?: string
|
|
21
|
+
x?: number
|
|
22
|
+
y?: number
|
|
23
|
+
target?: string
|
|
24
|
+
direction?: string
|
|
25
|
+
distance?: number
|
|
26
|
+
key?: string
|
|
27
|
+
}
|
|
28
|
+
import { parseLogEntries } from "./utils"
|
|
13
29
|
|
|
14
30
|
// Hook for dark mode with system preference detection
|
|
15
31
|
function useDarkMode() {
|
|
16
32
|
const [darkMode, setDarkMode] = useState<boolean>(() => {
|
|
17
33
|
if (typeof window !== "undefined") {
|
|
18
34
|
// Check localStorage first
|
|
19
|
-
const saved = localStorage.getItem("dev3000-dark-mode")
|
|
35
|
+
const saved = localStorage.getItem("dev3000-dark-mode")
|
|
20
36
|
if (saved !== null) {
|
|
21
|
-
return JSON.parse(saved)
|
|
37
|
+
return JSON.parse(saved)
|
|
22
38
|
}
|
|
23
39
|
// Default to system preference
|
|
24
|
-
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
40
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
25
41
|
}
|
|
26
|
-
return false
|
|
27
|
-
})
|
|
42
|
+
return false
|
|
43
|
+
})
|
|
28
44
|
|
|
29
45
|
useEffect(() => {
|
|
30
46
|
// Save to localStorage
|
|
31
|
-
localStorage.setItem("dev3000-dark-mode", JSON.stringify(darkMode))
|
|
47
|
+
localStorage.setItem("dev3000-dark-mode", JSON.stringify(darkMode))
|
|
32
48
|
|
|
33
49
|
// Apply dark class to document
|
|
34
50
|
if (darkMode) {
|
|
35
|
-
document.documentElement.classList.add("dark")
|
|
51
|
+
document.documentElement.classList.add("dark")
|
|
36
52
|
} else {
|
|
37
|
-
document.documentElement.classList.remove("dark")
|
|
53
|
+
document.documentElement.classList.remove("dark")
|
|
38
54
|
}
|
|
39
|
-
}, [darkMode])
|
|
55
|
+
}, [darkMode])
|
|
40
56
|
|
|
41
57
|
// Listen for system theme changes
|
|
42
58
|
useEffect(() => {
|
|
43
|
-
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
|
59
|
+
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
|
44
60
|
const handler = (e: MediaQueryListEvent) => {
|
|
45
61
|
// Only update if no explicit choice has been made
|
|
46
|
-
const saved = localStorage.getItem("dev3000-dark-mode")
|
|
62
|
+
const saved = localStorage.getItem("dev3000-dark-mode")
|
|
47
63
|
if (saved === null) {
|
|
48
|
-
setDarkMode(e.matches)
|
|
64
|
+
setDarkMode(e.matches)
|
|
49
65
|
}
|
|
50
|
-
}
|
|
66
|
+
}
|
|
51
67
|
|
|
52
|
-
mediaQuery.addEventListener("change", handler)
|
|
53
|
-
return () => mediaQuery.removeEventListener("change", handler)
|
|
54
|
-
}, [])
|
|
68
|
+
mediaQuery.addEventListener("change", handler)
|
|
69
|
+
return () => mediaQuery.removeEventListener("change", handler)
|
|
70
|
+
}, [])
|
|
55
71
|
|
|
56
|
-
return [darkMode, setDarkMode] as const
|
|
72
|
+
return [darkMode, setDarkMode] as const
|
|
57
73
|
}
|
|
58
74
|
|
|
59
75
|
// Keep this for backwards compatibility, but it's not used anymore
|
|
60
|
-
function
|
|
61
|
-
const match = line.match(/^\[([^\]]+)\] \[([^\]]+)\] (.*)$/s)
|
|
62
|
-
if (!match) return null
|
|
76
|
+
function _parseLogLine(line: string): LogEntry | null {
|
|
77
|
+
const match = line.match(/^\[([^\]]+)\] \[([^\]]+)\] (.*)$/s)
|
|
78
|
+
if (!match) return null
|
|
63
79
|
|
|
64
|
-
const [, timestamp, source, message] = match
|
|
65
|
-
const screenshot = message.match(/\[SCREENSHOT\] (.+)/)?.[1]
|
|
80
|
+
const [, timestamp, source, message] = match
|
|
81
|
+
const screenshot = message.match(/\[SCREENSHOT\] (.+)/)?.[1]
|
|
66
82
|
|
|
67
83
|
return {
|
|
68
84
|
timestamp,
|
|
69
85
|
source,
|
|
70
86
|
message,
|
|
71
87
|
screenshot,
|
|
72
|
-
original: line
|
|
73
|
-
}
|
|
88
|
+
original: line
|
|
89
|
+
}
|
|
74
90
|
}
|
|
75
91
|
|
|
76
92
|
// Component to render truncated URLs with click-to-expand
|
|
77
|
-
function URLRenderer({
|
|
78
|
-
|
|
79
|
-
maxLength = 60,
|
|
80
|
-
}: {
|
|
81
|
-
url: string;
|
|
82
|
-
maxLength?: number;
|
|
83
|
-
}) {
|
|
84
|
-
const [isExpanded, setIsExpanded] = useState(false);
|
|
93
|
+
function URLRenderer({ url, maxLength = 60 }: { url: string; maxLength?: number }) {
|
|
94
|
+
const [isExpanded, setIsExpanded] = useState(false)
|
|
85
95
|
|
|
86
96
|
if (url.length <= maxLength) {
|
|
87
97
|
return (
|
|
@@ -93,10 +103,10 @@ function URLRenderer({
|
|
|
93
103
|
>
|
|
94
104
|
{url}
|
|
95
105
|
</a>
|
|
96
|
-
)
|
|
106
|
+
)
|
|
97
107
|
}
|
|
98
108
|
|
|
99
|
-
const truncated = url.substring(0, maxLength)
|
|
109
|
+
const truncated = `${url.substring(0, maxLength)}...`
|
|
100
110
|
|
|
101
111
|
return (
|
|
102
112
|
<span className="inline-block">
|
|
@@ -111,6 +121,7 @@ function URLRenderer({
|
|
|
111
121
|
{url}
|
|
112
122
|
</a>
|
|
113
123
|
<button
|
|
124
|
+
type="button"
|
|
114
125
|
onClick={() => setIsExpanded(false)}
|
|
115
126
|
className="ml-2 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 px-1 py-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
116
127
|
>
|
|
@@ -128,6 +139,7 @@ function URLRenderer({
|
|
|
128
139
|
{truncated}
|
|
129
140
|
</a>
|
|
130
141
|
<button
|
|
142
|
+
type="button"
|
|
131
143
|
onClick={() => setIsExpanded(true)}
|
|
132
144
|
className="ml-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 px-1 py-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
133
145
|
>
|
|
@@ -136,37 +148,31 @@ function URLRenderer({
|
|
|
136
148
|
</span>
|
|
137
149
|
)}
|
|
138
150
|
</span>
|
|
139
|
-
)
|
|
151
|
+
)
|
|
140
152
|
}
|
|
141
153
|
|
|
142
154
|
// Component to render Chrome DevTools-style collapsible objects
|
|
143
155
|
function ObjectRenderer({ content }: { content: string }) {
|
|
144
|
-
const [isExpanded, setIsExpanded] = useState(false)
|
|
156
|
+
const [isExpanded, setIsExpanded] = useState(false)
|
|
145
157
|
|
|
146
158
|
try {
|
|
147
|
-
const obj = JSON.parse(content)
|
|
159
|
+
const obj = JSON.parse(content)
|
|
148
160
|
|
|
149
161
|
// Check if it's a Chrome DevTools object representation
|
|
150
|
-
if (
|
|
151
|
-
obj
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
obj.properties
|
|
155
|
-
) {
|
|
156
|
-
const properties = obj.properties;
|
|
157
|
-
const description = obj.description || "Object";
|
|
158
|
-
const overflow = obj.overflow;
|
|
162
|
+
if (obj && typeof obj === "object" && obj.type === "object" && obj.properties) {
|
|
163
|
+
const properties = obj.properties
|
|
164
|
+
const description = obj.description || "Object"
|
|
165
|
+
const overflow = obj.overflow
|
|
159
166
|
|
|
160
167
|
return (
|
|
161
168
|
<div className="inline-block">
|
|
162
169
|
<button
|
|
170
|
+
type="button"
|
|
163
171
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
164
172
|
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-800 font-mono text-sm"
|
|
165
173
|
>
|
|
166
174
|
<svg
|
|
167
|
-
className={`w-3 h-3 transition-transform ${
|
|
168
|
-
isExpanded ? "rotate-90" : ""
|
|
169
|
-
}`}
|
|
175
|
+
className={`w-3 h-3 transition-transform ${isExpanded ? "rotate-90" : ""}`}
|
|
170
176
|
fill="currentColor"
|
|
171
177
|
viewBox="0 0 20 20"
|
|
172
178
|
>
|
|
@@ -180,20 +186,20 @@ function ObjectRenderer({ content }: { content: string }) {
|
|
|
180
186
|
{!isExpanded && (
|
|
181
187
|
<span className="text-gray-500">
|
|
182
188
|
{overflow ? "..." : ""} {"{"}
|
|
183
|
-
{properties.slice(0, 3).map((prop:
|
|
184
|
-
<span key={idx}>
|
|
189
|
+
{properties.slice(0, 3).map((prop: PropertyData, idx: number) => (
|
|
190
|
+
<span key={`${prop.name}-${idx}`}>
|
|
185
191
|
{idx > 0 && ", "}
|
|
186
192
|
<span className="text-red-600">{prop.name}</span>:
|
|
187
193
|
<span className="text-blue-600">
|
|
188
194
|
{prop.type === "string"
|
|
189
195
|
? `"${prop.value}"`
|
|
190
196
|
: prop.type === "number"
|
|
191
|
-
? prop.value
|
|
192
|
-
: prop.type === "object"
|
|
193
|
-
? prop.subtype === "array"
|
|
194
197
|
? prop.value
|
|
195
|
-
: "
|
|
196
|
-
|
|
198
|
+
: prop.type === "object"
|
|
199
|
+
? prop.subtype === "array"
|
|
200
|
+
? prop.value
|
|
201
|
+
: "{...}"
|
|
202
|
+
: prop.value}
|
|
197
203
|
</span>
|
|
198
204
|
</span>
|
|
199
205
|
))}
|
|
@@ -209,8 +215,8 @@ function ObjectRenderer({ content }: { content: string }) {
|
|
|
209
215
|
<div className="text-gray-600">
|
|
210
216
|
{description} {"{"}
|
|
211
217
|
<div className="ml-4">
|
|
212
|
-
{properties.map((prop:
|
|
213
|
-
<div key={idx} className="py-0.5">
|
|
218
|
+
{properties.map((prop: PropertyData, idx: number) => (
|
|
219
|
+
<div key={`${prop.name}-${idx}`} className="py-0.5">
|
|
214
220
|
<span className="text-red-600">{prop.name}</span>
|
|
215
221
|
<span className="text-gray-500">: </span>
|
|
216
222
|
<span
|
|
@@ -218,32 +224,26 @@ function ObjectRenderer({ content }: { content: string }) {
|
|
|
218
224
|
prop.type === "string"
|
|
219
225
|
? "text-green-600"
|
|
220
226
|
: prop.type === "number"
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
227
|
+
? "text-blue-600"
|
|
228
|
+
: prop.type === "object"
|
|
229
|
+
? "text-purple-600"
|
|
230
|
+
: "text-orange-600"
|
|
225
231
|
}
|
|
226
232
|
>
|
|
227
233
|
{prop.type === "string"
|
|
228
|
-
? `"${prop.value}"`
|
|
234
|
+
? `"${String(prop.value)}"`
|
|
229
235
|
: prop.type === "number"
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
+
? String(prop.value)
|
|
237
|
+
: prop.type === "object"
|
|
238
|
+
? prop.subtype === "array"
|
|
239
|
+
? String(prop.value)
|
|
240
|
+
: "{...}"
|
|
241
|
+
: String(prop.value)}
|
|
236
242
|
</span>
|
|
237
|
-
{idx < properties.length - 1 &&
|
|
238
|
-
<span className="text-gray-500">,</span>
|
|
239
|
-
)}
|
|
243
|
+
{idx < properties.length - 1 && <span className="text-gray-500">,</span>}
|
|
240
244
|
</div>
|
|
241
245
|
))}
|
|
242
|
-
{overflow &&
|
|
243
|
-
<div className="text-gray-500 italic">
|
|
244
|
-
... and more properties
|
|
245
|
-
</div>
|
|
246
|
-
)}
|
|
246
|
+
{overflow && <div className="text-gray-500 italic">... and more properties</div>}
|
|
247
247
|
</div>
|
|
248
248
|
<div className="text-gray-600">{"}"}</div>
|
|
249
249
|
</div>
|
|
@@ -251,20 +251,19 @@ function ObjectRenderer({ content }: { content: string }) {
|
|
|
251
251
|
</div>
|
|
252
252
|
)}
|
|
253
253
|
</div>
|
|
254
|
-
)
|
|
254
|
+
)
|
|
255
255
|
}
|
|
256
256
|
|
|
257
257
|
// For regular JSON objects, render them nicely too
|
|
258
258
|
return (
|
|
259
259
|
<div className="inline-block">
|
|
260
260
|
<button
|
|
261
|
+
type="button"
|
|
261
262
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
262
263
|
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-800 font-mono text-sm"
|
|
263
264
|
>
|
|
264
265
|
<svg
|
|
265
|
-
className={`w-3 h-3 transition-transform ${
|
|
266
|
-
isExpanded ? "rotate-90" : ""
|
|
267
|
-
}`}
|
|
266
|
+
className={`w-3 h-3 transition-transform ${isExpanded ? "rotate-90" : ""}`}
|
|
268
267
|
fill="currentColor"
|
|
269
268
|
viewBox="0 0 20 20"
|
|
270
269
|
>
|
|
@@ -284,16 +283,14 @@ function ObjectRenderer({ content }: { content: string }) {
|
|
|
284
283
|
|
|
285
284
|
{isExpanded && (
|
|
286
285
|
<div className="mt-1 ml-4 border-l-2 border-gray-200 pl-3">
|
|
287
|
-
<pre className="font-mono text-sm text-gray-700 whitespace-pre-wrap">
|
|
288
|
-
{JSON.stringify(obj, null, 2)}
|
|
289
|
-
</pre>
|
|
286
|
+
<pre className="font-mono text-sm text-gray-700 whitespace-pre-wrap">{JSON.stringify(obj, null, 2)}</pre>
|
|
290
287
|
</div>
|
|
291
288
|
)}
|
|
292
289
|
</div>
|
|
293
|
-
)
|
|
294
|
-
} catch (
|
|
290
|
+
)
|
|
291
|
+
} catch (_e) {
|
|
295
292
|
// If it's not valid JSON, just return the original content
|
|
296
|
-
return <span>{content}</span
|
|
293
|
+
return <span>{content}</span>
|
|
297
294
|
}
|
|
298
295
|
}
|
|
299
296
|
|
|
@@ -303,83 +300,76 @@ function LogEntryComponent({ entry }: { entry: LogEntry }) {
|
|
|
303
300
|
if (message.includes("[INTERACTION]"))
|
|
304
301
|
return {
|
|
305
302
|
type: "INTERACTION",
|
|
306
|
-
color:
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
};
|
|
303
|
+
color: "bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800",
|
|
304
|
+
tag: "bg-purple-100 dark:bg-purple-800 text-purple-800 dark:text-purple-200"
|
|
305
|
+
}
|
|
310
306
|
if (message.includes("[CONSOLE ERROR]"))
|
|
311
307
|
return {
|
|
312
308
|
type: "ERROR",
|
|
313
|
-
color:
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
};
|
|
309
|
+
color: "bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800",
|
|
310
|
+
tag: "bg-red-100 dark:bg-red-800 text-red-800 dark:text-red-200"
|
|
311
|
+
}
|
|
317
312
|
if (message.includes("[CONSOLE WARN]"))
|
|
318
313
|
return {
|
|
319
314
|
type: "WARNING",
|
|
320
|
-
color:
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
};
|
|
315
|
+
color: "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800",
|
|
316
|
+
tag: "bg-yellow-100 dark:bg-yellow-800 text-yellow-800 dark:text-yellow-200"
|
|
317
|
+
}
|
|
324
318
|
if (message.includes("[SCREENSHOT]"))
|
|
325
319
|
return {
|
|
326
320
|
type: "SCREENSHOT",
|
|
327
|
-
color:
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
};
|
|
321
|
+
color: "bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800",
|
|
322
|
+
tag: "bg-blue-100 dark:bg-blue-800 text-blue-800 dark:text-blue-200"
|
|
323
|
+
}
|
|
331
324
|
if (message.includes("[NAVIGATION]"))
|
|
332
325
|
return {
|
|
333
326
|
type: "NAVIGATION",
|
|
334
|
-
color:
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
};
|
|
327
|
+
color: "bg-indigo-50 dark:bg-indigo-900/20 border-indigo-200 dark:border-indigo-800",
|
|
328
|
+
tag: "bg-indigo-100 dark:bg-indigo-800 text-indigo-800 dark:text-indigo-200"
|
|
329
|
+
}
|
|
338
330
|
if (message.includes("[NETWORK ERROR]"))
|
|
339
331
|
return {
|
|
340
332
|
type: "NETWORK",
|
|
341
|
-
color:
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
};
|
|
333
|
+
color: "bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800",
|
|
334
|
+
tag: "bg-red-100 dark:bg-red-800 text-red-800 dark:text-red-200"
|
|
335
|
+
}
|
|
345
336
|
if (message.includes("[NETWORK REQUEST]"))
|
|
346
337
|
return {
|
|
347
338
|
type: "NETWORK",
|
|
348
|
-
color:
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
};
|
|
339
|
+
color: "bg-gray-50 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700",
|
|
340
|
+
tag: "bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
|
|
341
|
+
}
|
|
352
342
|
if (message.includes("[PAGE ERROR]"))
|
|
353
343
|
return {
|
|
354
344
|
type: "ERROR",
|
|
355
|
-
color:
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
};
|
|
345
|
+
color: "bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800",
|
|
346
|
+
tag: "bg-red-100 dark:bg-red-800 text-red-800 dark:text-red-200"
|
|
347
|
+
}
|
|
359
348
|
return {
|
|
360
349
|
type: "DEFAULT",
|
|
361
350
|
color: "border-gray-200 dark:border-gray-700",
|
|
362
|
-
tag: "bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
|
|
363
|
-
}
|
|
364
|
-
}
|
|
351
|
+
tag: "bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
|
|
352
|
+
}
|
|
353
|
+
}
|
|
365
354
|
|
|
366
|
-
const logTypeInfo = parseLogType(entry.message)
|
|
355
|
+
const logTypeInfo = parseLogType(entry.message)
|
|
367
356
|
|
|
368
357
|
// Extract and highlight type tags, detect JSON objects and URLs
|
|
369
358
|
const renderMessage = (message: string) => {
|
|
370
|
-
const typeTagRegex = /\[([A-Z\s]+)\]/g
|
|
371
|
-
const jsonRegex = /\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g
|
|
372
|
-
const urlRegex = /(https?:\/\/[^\s]+)/g
|
|
359
|
+
const typeTagRegex = /\[([A-Z\s]+)\]/g
|
|
360
|
+
const jsonRegex = /\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g
|
|
361
|
+
const urlRegex = /(https?:\/\/[^\s]+)/g
|
|
373
362
|
|
|
374
|
-
const parts = []
|
|
375
|
-
let lastIndex = 0
|
|
376
|
-
let match
|
|
363
|
+
const parts = []
|
|
364
|
+
let lastIndex = 0
|
|
365
|
+
let match: RegExpExecArray | null
|
|
377
366
|
|
|
378
367
|
// First, handle type tags
|
|
379
|
-
|
|
368
|
+
match = typeTagRegex.exec(message)
|
|
369
|
+
while (match !== null) {
|
|
380
370
|
// Add text before the tag
|
|
381
371
|
if (match.index > lastIndex) {
|
|
382
|
-
parts.push(message.slice(lastIndex, match.index))
|
|
372
|
+
parts.push(message.slice(lastIndex, match.index))
|
|
383
373
|
}
|
|
384
374
|
|
|
385
375
|
// Add the tag with styling
|
|
@@ -390,69 +380,68 @@ function LogEntryComponent({ entry }: { entry: LogEntry }) {
|
|
|
390
380
|
>
|
|
391
381
|
{match[1]}
|
|
392
382
|
</span>
|
|
393
|
-
)
|
|
383
|
+
)
|
|
394
384
|
|
|
395
|
-
lastIndex = match.index + match[0].length
|
|
385
|
+
lastIndex = match.index + match[0].length
|
|
386
|
+
match = typeTagRegex.exec(message)
|
|
396
387
|
}
|
|
397
388
|
|
|
398
389
|
// Add remaining text
|
|
399
|
-
|
|
390
|
+
const remainingText = message.slice(lastIndex)
|
|
400
391
|
|
|
401
392
|
// Process remaining text for JSON objects and URLs
|
|
402
393
|
const processTextForObjects = (text: string, keyPrefix: string) => {
|
|
403
|
-
const jsonMatches = [...text.matchAll(jsonRegex)]
|
|
404
|
-
const urlMatches = [...text.matchAll(urlRegex)]
|
|
394
|
+
const jsonMatches = [...text.matchAll(jsonRegex)]
|
|
395
|
+
const urlMatches = [...text.matchAll(urlRegex)]
|
|
405
396
|
const allMatches = [
|
|
406
397
|
...jsonMatches.map((m) => ({ ...m, type: "json" })),
|
|
407
|
-
...urlMatches.map((m) => ({ ...m, type: "url" }))
|
|
408
|
-
]
|
|
398
|
+
...urlMatches.map((m) => ({ ...m, type: "url" }))
|
|
399
|
+
]
|
|
409
400
|
|
|
410
401
|
// Sort matches by index
|
|
411
|
-
allMatches.sort((a, b) => a.index
|
|
402
|
+
allMatches.sort((a, b) => (a.index ?? 0) - (b.index ?? 0))
|
|
412
403
|
|
|
413
404
|
if (allMatches.length === 0) {
|
|
414
|
-
return [text]
|
|
405
|
+
return [text]
|
|
415
406
|
}
|
|
416
407
|
|
|
417
|
-
const finalParts = []
|
|
418
|
-
let textLastIndex = 0
|
|
408
|
+
const finalParts = []
|
|
409
|
+
let textLastIndex = 0
|
|
419
410
|
|
|
420
|
-
allMatches.forEach((objMatch,
|
|
411
|
+
allMatches.forEach((objMatch, _idx) => {
|
|
421
412
|
// Add text before match
|
|
422
|
-
if (objMatch.index
|
|
423
|
-
finalParts.push(text.slice(textLastIndex, objMatch.index))
|
|
413
|
+
if ((objMatch.index ?? 0) > textLastIndex) {
|
|
414
|
+
finalParts.push(text.slice(textLastIndex, objMatch.index ?? 0))
|
|
424
415
|
}
|
|
425
416
|
|
|
426
417
|
// Add appropriate renderer
|
|
427
418
|
if (objMatch.type === "json") {
|
|
428
419
|
finalParts.push(
|
|
429
420
|
<ObjectRenderer
|
|
430
|
-
key={`${keyPrefix}-json-${
|
|
421
|
+
key={`${keyPrefix}-json-${objMatch.index}-${objMatch[0].slice(0, 20)}`}
|
|
431
422
|
content={objMatch[0]}
|
|
432
423
|
/>
|
|
433
|
-
)
|
|
424
|
+
)
|
|
434
425
|
} else if (objMatch.type === "url") {
|
|
435
|
-
finalParts.push(
|
|
436
|
-
<URLRenderer key={`${keyPrefix}-url-${idx}`} url={objMatch[0]} />
|
|
437
|
-
);
|
|
426
|
+
finalParts.push(<URLRenderer key={`${keyPrefix}-url-${objMatch.index}-${objMatch[0]}`} url={objMatch[0]} />)
|
|
438
427
|
}
|
|
439
428
|
|
|
440
|
-
textLastIndex = objMatch.index
|
|
441
|
-
})
|
|
429
|
+
textLastIndex = (objMatch.index ?? 0) + objMatch[0].length
|
|
430
|
+
})
|
|
442
431
|
|
|
443
432
|
// Add any text after the last match
|
|
444
433
|
if (textLastIndex < text.length) {
|
|
445
|
-
finalParts.push(text.slice(textLastIndex))
|
|
434
|
+
finalParts.push(text.slice(textLastIndex))
|
|
446
435
|
}
|
|
447
436
|
|
|
448
|
-
return finalParts
|
|
449
|
-
}
|
|
437
|
+
return finalParts
|
|
438
|
+
}
|
|
450
439
|
|
|
451
|
-
const processedRemaining = processTextForObjects(remainingText, "main")
|
|
452
|
-
parts.push(...processedRemaining)
|
|
440
|
+
const processedRemaining = processTextForObjects(remainingText, "main")
|
|
441
|
+
parts.push(...processedRemaining)
|
|
453
442
|
|
|
454
|
-
return parts.length > 0 ? parts : message
|
|
455
|
-
}
|
|
443
|
+
return parts.length > 0 ? parts : message
|
|
444
|
+
}
|
|
456
445
|
|
|
457
446
|
return (
|
|
458
447
|
<div className={`border-l-4 ${logTypeInfo.color} pl-4 py-2`}>
|
|
@@ -474,257 +463,366 @@ function LogEntryComponent({ entry }: { entry: LogEntry }) {
|
|
|
474
463
|
{entry.source}
|
|
475
464
|
</div>
|
|
476
465
|
|
|
477
|
-
{/* Column 3: Message content */}
|
|
466
|
+
{/* Column 3: Message content with user agent info */}
|
|
478
467
|
<div className="font-mono text-sm min-w-0 text-gray-900 dark:text-gray-100">
|
|
479
|
-
|
|
468
|
+
<div className="flex flex-wrap items-start gap-2">
|
|
469
|
+
<div className="flex-1 min-w-0">{renderMessage(entry.message)}</div>
|
|
470
|
+
{/* User Agent and Tab Identifier Pills */}
|
|
471
|
+
<div className="flex items-center gap-1 flex-shrink-0">
|
|
472
|
+
{entry.tabIdentifier && (
|
|
473
|
+
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300">
|
|
474
|
+
{entry.tabIdentifier}
|
|
475
|
+
</span>
|
|
476
|
+
)}
|
|
477
|
+
{entry.userAgent && (
|
|
478
|
+
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-800 text-green-700 dark:text-green-200">
|
|
479
|
+
{entry.userAgent.includes("Chrome")
|
|
480
|
+
? "Chrome"
|
|
481
|
+
: entry.userAgent.includes("Firefox")
|
|
482
|
+
? "Firefox"
|
|
483
|
+
: entry.userAgent.includes("Safari")
|
|
484
|
+
? "Safari"
|
|
485
|
+
: entry.userAgent.includes("Edge")
|
|
486
|
+
? "Edge"
|
|
487
|
+
: "Browser"}
|
|
488
|
+
</span>
|
|
489
|
+
)}
|
|
490
|
+
</div>
|
|
491
|
+
</div>
|
|
480
492
|
</div>
|
|
481
493
|
</div>
|
|
482
494
|
|
|
483
495
|
{entry.screenshot && (
|
|
484
496
|
<div className="mt-2">
|
|
485
|
-
<
|
|
497
|
+
<Image
|
|
486
498
|
src={`/screenshots/${entry.screenshot}`}
|
|
487
499
|
alt="Screenshot"
|
|
500
|
+
width={800}
|
|
501
|
+
height={400}
|
|
488
502
|
className="max-w-full h-auto border rounded shadow-sm"
|
|
489
503
|
style={{ maxHeight: "400px" }}
|
|
504
|
+
unoptimized
|
|
490
505
|
/>
|
|
491
506
|
</div>
|
|
492
507
|
)}
|
|
493
508
|
</div>
|
|
494
|
-
)
|
|
509
|
+
)
|
|
495
510
|
}
|
|
496
511
|
|
|
497
512
|
interface LogsClientProps {
|
|
498
|
-
version: string
|
|
513
|
+
version: string
|
|
499
514
|
initialData?: {
|
|
500
|
-
logs: LogEntry[]
|
|
501
|
-
logFiles:
|
|
502
|
-
currentLogFile: string
|
|
503
|
-
mode: "head" | "tail"
|
|
504
|
-
}
|
|
515
|
+
logs: LogEntry[]
|
|
516
|
+
logFiles: LogFile[]
|
|
517
|
+
currentLogFile: string
|
|
518
|
+
mode: "head" | "tail"
|
|
519
|
+
}
|
|
505
520
|
}
|
|
506
521
|
|
|
507
522
|
export default function LogsClient({ version, initialData }: LogsClientProps) {
|
|
508
|
-
const router = useRouter()
|
|
509
|
-
const searchParams = useSearchParams()
|
|
510
|
-
const [darkMode, setDarkMode] = useDarkMode()
|
|
511
|
-
const [logs, setLogs] = useState<LogEntry[]>(initialData?.logs || [])
|
|
512
|
-
const [mode, setMode] = useState<"head" | "tail">(
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
)
|
|
521
|
-
const [
|
|
522
|
-
const [
|
|
523
|
-
|
|
524
|
-
)
|
|
525
|
-
const [
|
|
526
|
-
|
|
527
|
-
)
|
|
528
|
-
const [
|
|
529
|
-
const [
|
|
530
|
-
const [
|
|
531
|
-
const [
|
|
532
|
-
const [
|
|
533
|
-
const [
|
|
534
|
-
const [isRotatingLog, setIsRotatingLog] = useState(false);
|
|
523
|
+
const router = useRouter()
|
|
524
|
+
const searchParams = useSearchParams()
|
|
525
|
+
const [darkMode, setDarkMode] = useDarkMode()
|
|
526
|
+
const [logs, setLogs] = useState<LogEntry[]>(initialData?.logs || [])
|
|
527
|
+
const [mode, setMode] = useState<"head" | "tail">(initialData?.mode || "tail")
|
|
528
|
+
|
|
529
|
+
// Update mode when URL parameters change
|
|
530
|
+
useEffect(() => {
|
|
531
|
+
const urlMode = searchParams.get("mode") as "head" | "tail" | null
|
|
532
|
+
if (urlMode && urlMode !== mode) {
|
|
533
|
+
setMode(urlMode)
|
|
534
|
+
}
|
|
535
|
+
}, [searchParams, mode])
|
|
536
|
+
const [isAtBottom, setIsAtBottom] = useState(true)
|
|
537
|
+
const [isInitialLoading, setIsInitialLoading] = useState(!initialData)
|
|
538
|
+
const [lastLogCount, setLastLogCount] = useState(initialData?.logs.length || 0)
|
|
539
|
+
const [lastFetched, setLastFetched] = useState<Date | null>(null)
|
|
540
|
+
const [availableLogs, setAvailableLogs] = useState<LogFile[]>(initialData?.logFiles || [])
|
|
541
|
+
const [currentLogFile, setCurrentLogFile] = useState<string>(initialData?.currentLogFile || "")
|
|
542
|
+
const [projectName, setProjectName] = useState<string>("")
|
|
543
|
+
const [showLogSelector, setShowLogSelector] = useState(false)
|
|
544
|
+
const [isReplaying, setIsReplaying] = useState(false)
|
|
545
|
+
const [showFilters, setShowFilters] = useState(false)
|
|
546
|
+
const [showReplayPreview, setShowReplayPreview] = useState(false)
|
|
547
|
+
const [replayEvents, setReplayEvents] = useState<ReplayEvent[]>([])
|
|
548
|
+
const [isRotatingLog, setIsRotatingLog] = useState(false)
|
|
535
549
|
const [filters, setFilters] = useState({
|
|
536
550
|
browser: true,
|
|
537
551
|
server: true,
|
|
538
552
|
interaction: true,
|
|
539
|
-
screenshot: true
|
|
540
|
-
})
|
|
541
|
-
const
|
|
542
|
-
const
|
|
543
|
-
const
|
|
544
|
-
const
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
|
|
553
|
+
screenshot: true
|
|
554
|
+
})
|
|
555
|
+
const [userAgentFilters, setUserAgentFilters] = useState<Record<string, boolean>>({})
|
|
556
|
+
const bottomRef = useRef<HTMLDivElement>(null)
|
|
557
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
558
|
+
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
|
559
|
+
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
560
|
+
const filterDropdownRef = useRef<HTMLDivElement>(null)
|
|
561
|
+
|
|
562
|
+
const loadAvailableLogs = useCallback(async () => {
|
|
548
563
|
try {
|
|
549
|
-
const response = await fetch("/api/logs/list")
|
|
564
|
+
const response = await fetch("/api/logs/list")
|
|
550
565
|
if (response.ok) {
|
|
551
|
-
const data: LogListResponse = await response.json()
|
|
552
|
-
setAvailableLogs(data.files)
|
|
553
|
-
setCurrentLogFile(data.currentFile)
|
|
554
|
-
setProjectName(data.projectName)
|
|
566
|
+
const data: LogListResponse = await response.json()
|
|
567
|
+
setAvailableLogs(data.files)
|
|
568
|
+
setCurrentLogFile(data.currentFile)
|
|
569
|
+
setProjectName(data.projectName)
|
|
555
570
|
}
|
|
556
571
|
} catch (error) {
|
|
557
|
-
console.error("Error loading available logs:", error)
|
|
572
|
+
console.error("Error loading available logs:", error)
|
|
558
573
|
}
|
|
559
|
-
}
|
|
574
|
+
}, [])
|
|
560
575
|
|
|
561
|
-
const pollForNewLogs = async () => {
|
|
562
|
-
if (mode !== "tail" || !isAtBottom) return
|
|
576
|
+
const pollForNewLogs = useCallback(async () => {
|
|
577
|
+
if (mode !== "tail" || !isAtBottom) return
|
|
563
578
|
|
|
564
579
|
try {
|
|
565
|
-
|
|
580
|
+
// Determine which log file to poll
|
|
581
|
+
const requestedFile = searchParams.get("file")
|
|
582
|
+
let logPath = ""
|
|
583
|
+
let isCurrentFile = true
|
|
584
|
+
|
|
585
|
+
if (requestedFile && availableLogs.length > 0) {
|
|
586
|
+
// Find the specific log file requested
|
|
587
|
+
const foundFile = availableLogs.find((f) => f.name === requestedFile)
|
|
588
|
+
logPath = foundFile?.path || currentLogFile
|
|
589
|
+
isCurrentFile = foundFile?.isCurrent !== false
|
|
590
|
+
} else {
|
|
591
|
+
// Use current log file
|
|
592
|
+
logPath = currentLogFile
|
|
593
|
+
isCurrentFile = true
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Only poll for new logs if viewing the current (active) log file
|
|
597
|
+
if (!isCurrentFile) return
|
|
598
|
+
|
|
599
|
+
// Build API URL with logPath parameter if needed
|
|
600
|
+
const apiUrl = logPath
|
|
601
|
+
? `/api/logs/tail?lines=1000&logPath=${encodeURIComponent(logPath)}`
|
|
602
|
+
: `/api/logs/tail?lines=1000`
|
|
603
|
+
|
|
604
|
+
const response = await fetch(apiUrl)
|
|
566
605
|
if (!response.ok) {
|
|
567
|
-
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
606
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
568
607
|
}
|
|
569
608
|
|
|
570
|
-
const data: LogsApiResponse = await response.json()
|
|
609
|
+
const data: LogsApiResponse = await response.json()
|
|
571
610
|
|
|
572
611
|
if (!data.logs) {
|
|
573
|
-
console.warn("No logs data in response")
|
|
574
|
-
return
|
|
612
|
+
console.warn("No logs data in response")
|
|
613
|
+
return
|
|
575
614
|
}
|
|
576
615
|
|
|
577
|
-
const entries = parseLogEntries(data.logs)
|
|
616
|
+
const entries = parseLogEntries(data.logs)
|
|
578
617
|
|
|
579
618
|
if (entries.length > lastLogCount) {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
setIsLoadingNew(false);
|
|
586
|
-
// Auto-scroll to bottom for new content
|
|
619
|
+
setLastFetched(new Date())
|
|
620
|
+
setLogs(entries)
|
|
621
|
+
setLastLogCount(entries.length)
|
|
622
|
+
// Only auto-scroll if user is at bottom (don't force scroll when user scrolled up)
|
|
623
|
+
if (isAtBottom) {
|
|
587
624
|
setTimeout(() => {
|
|
588
|
-
bottomRef.current?.scrollIntoView({ behavior: "smooth" })
|
|
589
|
-
}, 50)
|
|
590
|
-
}
|
|
625
|
+
bottomRef.current?.scrollIntoView({ behavior: "smooth" })
|
|
626
|
+
}, 50)
|
|
627
|
+
}
|
|
591
628
|
}
|
|
592
629
|
} catch (error) {
|
|
593
|
-
console.error("Error polling logs:", error)
|
|
630
|
+
console.error("Error polling logs:", error)
|
|
594
631
|
// Don't spam console on network errors during polling
|
|
595
632
|
}
|
|
596
|
-
}
|
|
633
|
+
}, [mode, isAtBottom, searchParams, availableLogs, currentLogFile, lastLogCount])
|
|
597
634
|
|
|
598
635
|
// Start/stop polling based on mode and scroll position
|
|
599
636
|
useEffect(() => {
|
|
600
637
|
if (mode === "tail" && isAtBottom) {
|
|
601
|
-
pollIntervalRef.current = setInterval(pollForNewLogs, 2000)
|
|
638
|
+
pollIntervalRef.current = setInterval(pollForNewLogs, 2000) // Poll every 2 seconds
|
|
602
639
|
return () => {
|
|
603
640
|
if (pollIntervalRef.current) {
|
|
604
|
-
clearInterval(pollIntervalRef.current)
|
|
641
|
+
clearInterval(pollIntervalRef.current)
|
|
605
642
|
}
|
|
606
|
-
}
|
|
643
|
+
}
|
|
607
644
|
} else {
|
|
608
645
|
if (pollIntervalRef.current) {
|
|
609
|
-
clearInterval(pollIntervalRef.current)
|
|
646
|
+
clearInterval(pollIntervalRef.current)
|
|
610
647
|
}
|
|
611
648
|
}
|
|
612
|
-
}, [mode, isAtBottom,
|
|
649
|
+
}, [mode, isAtBottom, pollForNewLogs]) // Remove lastLogCount to avoid excessive polling restarts
|
|
613
650
|
|
|
614
|
-
const loadInitialLogs = async () => {
|
|
615
|
-
setIsInitialLoading(true)
|
|
651
|
+
const loadInitialLogs = useCallback(async () => {
|
|
652
|
+
setIsInitialLoading(true)
|
|
616
653
|
|
|
617
654
|
// Load available logs list first
|
|
618
|
-
await loadAvailableLogs()
|
|
655
|
+
await loadAvailableLogs()
|
|
619
656
|
|
|
620
657
|
try {
|
|
621
|
-
|
|
658
|
+
// Determine which log file to load
|
|
659
|
+
const requestedFile = searchParams.get("file")
|
|
660
|
+
let logPath = ""
|
|
661
|
+
|
|
662
|
+
if (requestedFile && availableLogs.length > 0) {
|
|
663
|
+
// Find the specific log file requested
|
|
664
|
+
const foundFile = availableLogs.find((f) => f.name === requestedFile)
|
|
665
|
+
logPath = foundFile?.path || currentLogFile
|
|
666
|
+
} else {
|
|
667
|
+
// Use current log file
|
|
668
|
+
logPath = currentLogFile
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Build API URL with logPath parameter if needed
|
|
672
|
+
const apiUrl = logPath
|
|
673
|
+
? `/api/logs/${mode}?lines=1000&logPath=${encodeURIComponent(logPath)}`
|
|
674
|
+
: `/api/logs/${mode}?lines=1000`
|
|
675
|
+
|
|
676
|
+
const response = await fetch(apiUrl)
|
|
622
677
|
if (!response.ok) {
|
|
623
|
-
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
678
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
624
679
|
}
|
|
625
680
|
|
|
626
|
-
const data: LogsApiResponse = await response.json()
|
|
681
|
+
const data: LogsApiResponse = await response.json()
|
|
627
682
|
|
|
628
683
|
if (!data.logs) {
|
|
629
|
-
console.warn("No logs data in response")
|
|
630
|
-
setLogs([])
|
|
631
|
-
setIsInitialLoading(false)
|
|
632
|
-
return
|
|
684
|
+
console.warn("No logs data in response")
|
|
685
|
+
setLogs([])
|
|
686
|
+
setIsInitialLoading(false)
|
|
687
|
+
return
|
|
633
688
|
}
|
|
634
689
|
|
|
635
|
-
const entries = parseLogEntries(data.logs)
|
|
690
|
+
const entries = parseLogEntries(data.logs)
|
|
636
691
|
|
|
637
|
-
setLogs(entries)
|
|
638
|
-
setLastLogCount(entries.length)
|
|
639
|
-
setLastFetched(new Date())
|
|
640
|
-
setIsInitialLoading(false)
|
|
692
|
+
setLogs(entries)
|
|
693
|
+
setLastLogCount(entries.length)
|
|
694
|
+
setLastFetched(new Date())
|
|
695
|
+
setIsInitialLoading(false)
|
|
641
696
|
|
|
642
697
|
// Auto-scroll to bottom for tail mode
|
|
643
698
|
if (mode === "tail") {
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
699
|
+
setIsAtBottom(true)
|
|
700
|
+
const scrollToBottom = () => {
|
|
701
|
+
if (bottomRef.current) {
|
|
702
|
+
bottomRef.current.scrollIntoView({ behavior: "auto" })
|
|
703
|
+
} else if (containerRef.current) {
|
|
704
|
+
containerRef.current.scrollTop = containerRef.current.scrollHeight
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
// Try multiple times to ensure it works
|
|
708
|
+
setTimeout(scrollToBottom, 0)
|
|
709
|
+
setTimeout(scrollToBottom, 100)
|
|
648
710
|
}
|
|
649
711
|
} catch (error) {
|
|
650
|
-
console.error("Error loading logs:", error)
|
|
651
|
-
setLogs([])
|
|
712
|
+
console.error("Error loading logs:", error)
|
|
713
|
+
setLogs([])
|
|
652
714
|
}
|
|
653
|
-
}
|
|
715
|
+
}, [loadAvailableLogs, searchParams, availableLogs, currentLogFile, mode])
|
|
654
716
|
|
|
655
717
|
useEffect(() => {
|
|
656
|
-
// Only load
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
718
|
+
// Only load logs if we don't have initial data or mode actually changed
|
|
719
|
+
const _currentMode = searchParams.get("mode") || "tail"
|
|
720
|
+
const hasInitialData = initialData?.logs && initialData?.logs.length > 0
|
|
721
|
+
|
|
722
|
+
if (!hasInitialData && !logs.length) {
|
|
723
|
+
// No server-side data and no client data - load fresh
|
|
724
|
+
loadInitialLogs()
|
|
725
|
+
} else if (hasInitialData && logs.length === 0) {
|
|
726
|
+
// We have server-side data but client state is empty - use server data
|
|
727
|
+
setLogs(initialData.logs)
|
|
728
|
+
setIsInitialLoading(false)
|
|
729
|
+
if (mode === "tail") {
|
|
730
|
+
// Scroll to bottom for tail mode with server data - more aggressive approach
|
|
731
|
+
setIsAtBottom(true)
|
|
732
|
+
const scrollToBottom = () => {
|
|
733
|
+
if (bottomRef.current) {
|
|
734
|
+
bottomRef.current.scrollIntoView({ behavior: "auto" })
|
|
735
|
+
} else if (containerRef.current) {
|
|
736
|
+
containerRef.current.scrollTop = containerRef.current.scrollHeight
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
// Try multiple times to ensure it works
|
|
740
|
+
setTimeout(scrollToBottom, 0)
|
|
741
|
+
setTimeout(scrollToBottom, 100)
|
|
742
|
+
setTimeout(scrollToBottom, 300)
|
|
667
743
|
}
|
|
744
|
+
} else if (mode === "tail" && isAtBottom) {
|
|
745
|
+
// Set up polling timer for new logs if we're in tail mode
|
|
746
|
+
setIsInitialLoading(false)
|
|
747
|
+
pollIntervalRef.current = setInterval(() => {
|
|
748
|
+
pollForNewLogs()
|
|
749
|
+
}, 2000)
|
|
668
750
|
}
|
|
669
|
-
}, [
|
|
751
|
+
}, [
|
|
752
|
+
mode,
|
|
753
|
+
initialData?.logs,
|
|
754
|
+
isAtBottom, // No server-side data and no client data - load fresh
|
|
755
|
+
loadInitialLogs,
|
|
756
|
+
logs.length,
|
|
757
|
+
pollForNewLogs,
|
|
758
|
+
searchParams.get
|
|
759
|
+
]) // Only depend on mode to avoid infinite loops
|
|
760
|
+
|
|
761
|
+
// Separate effect to handle scrolling after logs are rendered
|
|
762
|
+
useEffect(() => {
|
|
763
|
+
if (logs.length > 0 && mode === "tail" && isAtBottom) {
|
|
764
|
+
const scrollToBottom = () => {
|
|
765
|
+
if (bottomRef.current) {
|
|
766
|
+
bottomRef.current.scrollIntoView({ behavior: "auto" })
|
|
767
|
+
} else if (containerRef.current) {
|
|
768
|
+
containerRef.current.scrollTop = containerRef.current.scrollHeight
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
// Scroll after DOM updates
|
|
772
|
+
setTimeout(scrollToBottom, 0)
|
|
773
|
+
}
|
|
774
|
+
}, [logs.length, mode, isAtBottom]) // Trigger when logs are actually rendered
|
|
670
775
|
|
|
671
776
|
useEffect(() => {
|
|
672
777
|
return () => {
|
|
673
778
|
if (pollIntervalRef.current) {
|
|
674
|
-
clearInterval(pollIntervalRef.current)
|
|
779
|
+
clearInterval(pollIntervalRef.current)
|
|
675
780
|
}
|
|
676
|
-
}
|
|
677
|
-
}, [])
|
|
781
|
+
}
|
|
782
|
+
}, [])
|
|
678
783
|
|
|
679
784
|
// Close dropdowns when clicking outside
|
|
680
785
|
useEffect(() => {
|
|
681
786
|
const handleClickOutside = (event: MouseEvent) => {
|
|
682
|
-
if (
|
|
683
|
-
|
|
684
|
-
!dropdownRef.current.contains(event.target as Node)
|
|
685
|
-
) {
|
|
686
|
-
setShowLogSelector(false);
|
|
787
|
+
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
788
|
+
setShowLogSelector(false)
|
|
687
789
|
}
|
|
688
|
-
if (
|
|
689
|
-
|
|
690
|
-
!filterDropdownRef.current.contains(event.target as Node)
|
|
691
|
-
) {
|
|
692
|
-
setShowFilters(false);
|
|
790
|
+
if (filterDropdownRef.current && !filterDropdownRef.current.contains(event.target as Node)) {
|
|
791
|
+
setShowFilters(false)
|
|
693
792
|
}
|
|
694
|
-
}
|
|
793
|
+
}
|
|
695
794
|
|
|
696
795
|
if (showLogSelector || showFilters) {
|
|
697
|
-
document.addEventListener("mousedown", handleClickOutside)
|
|
698
|
-
return () =>
|
|
699
|
-
document.removeEventListener("mousedown", handleClickOutside);
|
|
796
|
+
document.addEventListener("mousedown", handleClickOutside)
|
|
797
|
+
return () => document.removeEventListener("mousedown", handleClickOutside)
|
|
700
798
|
}
|
|
701
|
-
}, [showLogSelector, showFilters])
|
|
799
|
+
}, [showLogSelector, showFilters])
|
|
702
800
|
|
|
703
801
|
const handleScroll = () => {
|
|
704
802
|
if (containerRef.current) {
|
|
705
|
-
const { scrollTop, scrollHeight, clientHeight } = containerRef.current
|
|
706
|
-
const atBottom = scrollTop + clientHeight >= scrollHeight - 10
|
|
707
|
-
setIsAtBottom(atBottom)
|
|
803
|
+
const { scrollTop, scrollHeight, clientHeight } = containerRef.current
|
|
804
|
+
const atBottom = scrollTop + clientHeight >= scrollHeight - 10
|
|
805
|
+
setIsAtBottom(atBottom)
|
|
708
806
|
}
|
|
709
|
-
}
|
|
807
|
+
}
|
|
710
808
|
|
|
711
809
|
const handleReplay = async () => {
|
|
712
|
-
if (isReplaying) return
|
|
810
|
+
if (isReplaying) return
|
|
713
811
|
|
|
714
|
-
setIsReplaying(true)
|
|
812
|
+
setIsReplaying(true)
|
|
715
813
|
|
|
716
814
|
try {
|
|
717
815
|
// Get replay data from logs
|
|
718
|
-
const response = await fetch("/api/replay?action=parse")
|
|
816
|
+
const response = await fetch("/api/replay?action=parse")
|
|
719
817
|
if (!response.ok) {
|
|
720
|
-
throw new Error("Failed to parse replay data")
|
|
818
|
+
throw new Error("Failed to parse replay data")
|
|
721
819
|
}
|
|
722
820
|
|
|
723
|
-
const replayData = await response.json()
|
|
821
|
+
const replayData = await response.json()
|
|
724
822
|
|
|
725
823
|
if (replayData.interactions.length === 0) {
|
|
726
|
-
alert("No user interactions found in logs to replay")
|
|
727
|
-
return
|
|
824
|
+
alert("No user interactions found in logs to replay")
|
|
825
|
+
return
|
|
728
826
|
}
|
|
729
827
|
|
|
730
828
|
// Generate CDP commands for replay
|
|
@@ -734,121 +832,173 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
|
|
|
734
832
|
body: JSON.stringify({
|
|
735
833
|
action: "execute",
|
|
736
834
|
replayData: replayData,
|
|
737
|
-
speed: 2
|
|
738
|
-
})
|
|
739
|
-
})
|
|
835
|
+
speed: 2
|
|
836
|
+
})
|
|
837
|
+
})
|
|
740
838
|
|
|
741
|
-
const result = await response2.json()
|
|
839
|
+
const result = await response2.json()
|
|
742
840
|
|
|
743
841
|
if (result.success) {
|
|
744
|
-
console.log("Replay executed successfully:", result)
|
|
745
|
-
alert(`Replay completed! Executed ${result.totalCommands} commands.`)
|
|
842
|
+
console.log("Replay executed successfully:", result)
|
|
843
|
+
alert(`Replay completed! Executed ${result.totalCommands} commands.`)
|
|
746
844
|
} else {
|
|
747
|
-
console.log("CDP execution failed, showing commands:", result)
|
|
845
|
+
console.log("CDP execution failed, showing commands:", result)
|
|
748
846
|
alert(
|
|
749
|
-
`CDP execution not available. Generated ${
|
|
750
|
-
|
|
751
|
-
} commands. Check console for details.`
|
|
752
|
-
);
|
|
847
|
+
`CDP execution not available. Generated ${result.commands?.length || 0} commands. Check console for details.`
|
|
848
|
+
)
|
|
753
849
|
}
|
|
754
850
|
} catch (error) {
|
|
755
|
-
console.error("Replay error:", error)
|
|
756
|
-
alert(
|
|
757
|
-
"Failed to start replay: " +
|
|
758
|
-
(error instanceof Error ? error.message : "Unknown error")
|
|
759
|
-
);
|
|
851
|
+
console.error("Replay error:", error)
|
|
852
|
+
alert(`Failed to start replay: ${error instanceof Error ? error.message : "Unknown error"}`)
|
|
760
853
|
} finally {
|
|
761
|
-
setIsReplaying(false)
|
|
854
|
+
setIsReplaying(false)
|
|
762
855
|
}
|
|
763
|
-
}
|
|
856
|
+
}
|
|
764
857
|
|
|
765
858
|
const loadReplayPreview = () => {
|
|
766
859
|
// Extract interactions from current logs instead of making API call
|
|
767
860
|
const interactions = logs
|
|
768
861
|
.filter((log) => log.message.includes("[INTERACTION]"))
|
|
769
862
|
.map((log) => {
|
|
770
|
-
const match = log.message.match(/\[INTERACTION\] (.+)/)
|
|
863
|
+
const match = log.message.match(/\[INTERACTION\] (.+)/)
|
|
771
864
|
if (match) {
|
|
772
865
|
try {
|
|
773
866
|
// Try parsing as JSON (new format)
|
|
774
|
-
const data = JSON.parse(match[1])
|
|
867
|
+
const data = JSON.parse(match[1])
|
|
775
868
|
return {
|
|
776
869
|
timestamp: log.timestamp,
|
|
870
|
+
event: data.type || 'unknown',
|
|
871
|
+
details: JSON.stringify(data),
|
|
777
872
|
type: data.type,
|
|
778
|
-
|
|
779
|
-
|
|
873
|
+
x: data.x,
|
|
874
|
+
y: data.y,
|
|
875
|
+
target: data.target,
|
|
876
|
+
direction: data.direction,
|
|
877
|
+
distance: data.distance,
|
|
878
|
+
key: data.key
|
|
879
|
+
} as ReplayEvent
|
|
780
880
|
} catch {
|
|
781
881
|
// Fallback to old format parsing
|
|
782
|
-
const oldMatch = match[1].match(/(CLICK|TAP|SCROLL|KEY) (.+)/)
|
|
882
|
+
const oldMatch = match[1].match(/(CLICK|TAP|SCROLL|KEY) (.+)/)
|
|
783
883
|
if (oldMatch) {
|
|
784
884
|
return {
|
|
785
885
|
timestamp: log.timestamp,
|
|
786
|
-
|
|
886
|
+
event: oldMatch[1],
|
|
787
887
|
details: oldMatch[2],
|
|
788
|
-
|
|
888
|
+
type: oldMatch[1]
|
|
889
|
+
} as ReplayEvent
|
|
789
890
|
}
|
|
790
891
|
}
|
|
791
892
|
}
|
|
792
|
-
return null
|
|
893
|
+
return null
|
|
793
894
|
})
|
|
794
|
-
.filter(
|
|
895
|
+
.filter((item): item is ReplayEvent => item !== null)
|
|
795
896
|
|
|
796
|
-
setReplayEvents(interactions)
|
|
797
|
-
}
|
|
897
|
+
setReplayEvents(interactions)
|
|
898
|
+
}
|
|
798
899
|
|
|
799
900
|
const handleRotateLog = async () => {
|
|
800
|
-
if (!currentLogFile || isRotatingLog) return
|
|
901
|
+
if (!currentLogFile || isRotatingLog) return
|
|
902
|
+
|
|
903
|
+
const confirmed = window.confirm(
|
|
904
|
+
"Clear logs and start fresh?\n\n" +
|
|
905
|
+
"This will:\n" +
|
|
906
|
+
"• Archive the current log file\n" +
|
|
907
|
+
"• Start a new empty log file\n" +
|
|
908
|
+
"• Clear the current view\n\n" +
|
|
909
|
+
"The archived logs will still be available in the dropdown."
|
|
910
|
+
)
|
|
801
911
|
|
|
802
|
-
|
|
912
|
+
if (!confirmed) return
|
|
913
|
+
|
|
914
|
+
setIsRotatingLog(true)
|
|
803
915
|
try {
|
|
804
916
|
const response = await fetch("/api/logs/rotate", {
|
|
805
917
|
method: "POST",
|
|
806
918
|
headers: {
|
|
807
|
-
"Content-Type": "application/json"
|
|
919
|
+
"Content-Type": "application/json"
|
|
808
920
|
},
|
|
809
|
-
body: JSON.stringify({ currentLogPath: currentLogFile })
|
|
810
|
-
})
|
|
921
|
+
body: JSON.stringify({ currentLogPath: currentLogFile })
|
|
922
|
+
})
|
|
811
923
|
|
|
812
924
|
if (response.ok) {
|
|
813
925
|
// Clear current logs from UI
|
|
814
|
-
setLogs([])
|
|
815
|
-
setLastLogCount(0)
|
|
816
|
-
setLastFetched(null)
|
|
926
|
+
setLogs([])
|
|
927
|
+
setLastLogCount(0)
|
|
928
|
+
setLastFetched(null)
|
|
817
929
|
|
|
818
930
|
// Reload available logs to show the new archived file
|
|
819
|
-
await loadAvailableLogs()
|
|
931
|
+
await loadAvailableLogs()
|
|
820
932
|
|
|
821
933
|
// Start fresh polling
|
|
822
|
-
await loadInitialLogs()
|
|
934
|
+
await loadInitialLogs()
|
|
823
935
|
} else {
|
|
824
|
-
const error = await response.json()
|
|
825
|
-
console.error("Failed to rotate log:", error)
|
|
826
|
-
alert(
|
|
936
|
+
const error = await response.json()
|
|
937
|
+
console.error("Failed to rotate log:", error)
|
|
938
|
+
alert(`Failed to rotate log: ${error.error}`)
|
|
827
939
|
}
|
|
828
940
|
} catch (error) {
|
|
829
|
-
console.error("Error rotating log:", error)
|
|
830
|
-
alert("Error rotating log")
|
|
941
|
+
console.error("Error rotating log:", error)
|
|
942
|
+
alert("Error rotating log")
|
|
831
943
|
} finally {
|
|
832
|
-
setIsRotatingLog(false)
|
|
944
|
+
setIsRotatingLog(false)
|
|
833
945
|
}
|
|
834
|
-
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Compute available user agents from browser logs
|
|
949
|
+
const availableUserAgents = useMemo(() => {
|
|
950
|
+
const userAgents = new Set<string>()
|
|
951
|
+
logs.forEach((entry) => {
|
|
952
|
+
if (entry.source === "BROWSER" && entry.userAgent) {
|
|
953
|
+
userAgents.add(entry.userAgent)
|
|
954
|
+
}
|
|
955
|
+
})
|
|
956
|
+
return Array.from(userAgents).sort()
|
|
957
|
+
}, [logs])
|
|
958
|
+
|
|
959
|
+
// Update user agent filters when available user agents change
|
|
960
|
+
useEffect(() => {
|
|
961
|
+
if (availableUserAgents.length > 0) {
|
|
962
|
+
setUserAgentFilters((prev) => {
|
|
963
|
+
const newFilters = { ...prev }
|
|
964
|
+
// Enable all user agents by default if not already set
|
|
965
|
+
availableUserAgents.forEach((ua) => {
|
|
966
|
+
if (!(ua in newFilters)) {
|
|
967
|
+
newFilters[ua] = true
|
|
968
|
+
}
|
|
969
|
+
})
|
|
970
|
+
return newFilters
|
|
971
|
+
})
|
|
972
|
+
}
|
|
973
|
+
}, [availableUserAgents])
|
|
835
974
|
|
|
836
975
|
const filteredLogs = useMemo(() => {
|
|
837
976
|
return logs.filter((entry) => {
|
|
838
977
|
// Check specific message types first (these override source filtering)
|
|
839
|
-
const isInteraction = entry.message.includes("[INTERACTION]")
|
|
840
|
-
const isScreenshot = entry.message.includes("[SCREENSHOT]")
|
|
978
|
+
const isInteraction = entry.message.includes("[INTERACTION]")
|
|
979
|
+
const isScreenshot = entry.message.includes("[SCREENSHOT]")
|
|
841
980
|
|
|
842
|
-
if (isInteraction) return filters.interaction
|
|
843
|
-
if (isScreenshot) return filters.screenshot
|
|
981
|
+
if (isInteraction) return filters.interaction
|
|
982
|
+
if (isScreenshot) return filters.screenshot
|
|
844
983
|
|
|
845
984
|
// For other logs, filter by source
|
|
846
|
-
if (entry.source === "SERVER") return filters.server
|
|
847
|
-
if (entry.source === "BROWSER")
|
|
985
|
+
if (entry.source === "SERVER") return filters.server
|
|
986
|
+
if (entry.source === "BROWSER") {
|
|
987
|
+
// First check if browser logs are enabled at all
|
|
988
|
+
if (!filters.browser) return false
|
|
989
|
+
|
|
990
|
+
// If there are user agent filters and this entry has a user agent, apply UA filtering
|
|
991
|
+
if (availableUserAgents.length > 0 && entry.userAgent) {
|
|
992
|
+
return userAgentFilters[entry.userAgent] !== false
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// Otherwise, just show if browser is enabled
|
|
996
|
+
return true
|
|
997
|
+
}
|
|
848
998
|
|
|
849
|
-
return true
|
|
850
|
-
})
|
|
851
|
-
}, [logs, filters])
|
|
999
|
+
return true
|
|
1000
|
+
})
|
|
1001
|
+
}, [logs, filters, userAgentFilters, availableUserAgents])
|
|
852
1002
|
|
|
853
1003
|
return (
|
|
854
1004
|
<div className="h-screen bg-gray-50 dark:bg-gray-900 flex flex-col transition-colors">
|
|
@@ -861,14 +1011,13 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
|
|
|
861
1011
|
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-white whitespace-nowrap">
|
|
862
1012
|
dev3000
|
|
863
1013
|
</h1>
|
|
864
|
-
<span className="text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap">
|
|
865
|
-
(v{version})
|
|
866
|
-
</span>
|
|
1014
|
+
<span className="text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap">(v{version})</span>
|
|
867
1015
|
</div>
|
|
868
1016
|
{/* Log File Selector */}
|
|
869
1017
|
{availableLogs.length > 1 ? (
|
|
870
1018
|
<div className="relative" ref={dropdownRef}>
|
|
871
1019
|
<button
|
|
1020
|
+
type="button"
|
|
872
1021
|
onClick={() => setShowLogSelector(!showLogSelector)}
|
|
873
1022
|
className="flex items-center gap-2 px-3 py-1 text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-gray-700 rounded-md transition-colors"
|
|
874
1023
|
>
|
|
@@ -879,25 +1028,23 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
|
|
|
879
1028
|
style={{ width: "220px" }}
|
|
880
1029
|
/>
|
|
881
1030
|
) : currentLogFile ? (
|
|
882
|
-
|
|
1031
|
+
// Show symlink name for current file, basename for others
|
|
1032
|
+
availableLogs.find((log) => log.path === currentLogFile)?.isCurrent ? (
|
|
1033
|
+
"d3k.log"
|
|
1034
|
+
) : (
|
|
1035
|
+
currentLogFile.split("/").pop()
|
|
1036
|
+
)
|
|
883
1037
|
) : (
|
|
884
|
-
"
|
|
1038
|
+
"d3k.log"
|
|
885
1039
|
)}
|
|
886
1040
|
</span>
|
|
887
1041
|
<svg
|
|
888
|
-
className={`w-4 h-4 transition-transform ${
|
|
889
|
-
showLogSelector ? "rotate-180" : ""
|
|
890
|
-
}`}
|
|
1042
|
+
className={`w-4 h-4 transition-transform ${showLogSelector ? "rotate-180" : ""}`}
|
|
891
1043
|
fill="none"
|
|
892
1044
|
stroke="currentColor"
|
|
893
1045
|
viewBox="0 0 24 24"
|
|
894
1046
|
>
|
|
895
|
-
<path
|
|
896
|
-
strokeLinecap="round"
|
|
897
|
-
strokeLinejoin="round"
|
|
898
|
-
strokeWidth={2}
|
|
899
|
-
d="M19 9l-7 7-7-7"
|
|
900
|
-
/>
|
|
1047
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
901
1048
|
</svg>
|
|
902
1049
|
</button>
|
|
903
1050
|
{/* Dropdown */}
|
|
@@ -909,35 +1056,23 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
|
|
|
909
1056
|
</div>
|
|
910
1057
|
{availableLogs.map((logFile) => (
|
|
911
1058
|
<button
|
|
1059
|
+
type="button"
|
|
912
1060
|
key={logFile.path}
|
|
913
1061
|
onClick={() => {
|
|
914
|
-
setShowLogSelector(false)
|
|
915
|
-
router.push(
|
|
916
|
-
`/logs?file=${encodeURIComponent(
|
|
917
|
-
logFile.name
|
|
918
|
-
)}&mode=${mode}`
|
|
919
|
-
);
|
|
1062
|
+
setShowLogSelector(false)
|
|
1063
|
+
router.push(`/logs?file=${encodeURIComponent(logFile.name)}&mode=${mode}`)
|
|
920
1064
|
}}
|
|
921
1065
|
className={`w-full text-left px-3 py-2 text-sm hover:bg-gray-50 flex items-center justify-between ${
|
|
922
|
-
logFile.isCurrent
|
|
923
|
-
? "bg-blue-50 text-blue-900"
|
|
924
|
-
: "text-gray-700"
|
|
1066
|
+
logFile.isCurrent ? "bg-blue-50 text-blue-900" : "text-gray-700"
|
|
925
1067
|
}`}
|
|
926
1068
|
>
|
|
927
1069
|
<div className="flex flex-col">
|
|
928
|
-
<span className="font-mono text-xs">
|
|
929
|
-
{logFile.name}
|
|
930
|
-
</span>
|
|
1070
|
+
<span className="font-mono text-xs">{logFile.name}</span>
|
|
931
1071
|
<span className="text-xs text-gray-500">
|
|
932
|
-
{new Date(logFile.mtime).toLocaleString()} •{
|
|
933
|
-
{Math.round(logFile.size / 1024)}KB
|
|
1072
|
+
{new Date(logFile.mtime).toLocaleString()} • {Math.round(logFile.size / 1024)}KB
|
|
934
1073
|
</span>
|
|
935
1074
|
</div>
|
|
936
|
-
{logFile.isCurrent &&
|
|
937
|
-
<span className="text-xs text-blue-600 font-medium">
|
|
938
|
-
current
|
|
939
|
-
</span>
|
|
940
|
-
)}
|
|
1075
|
+
{logFile.isCurrent && <span className="text-xs text-blue-600 font-medium">current</span>}
|
|
941
1076
|
</button>
|
|
942
1077
|
))}
|
|
943
1078
|
</div>
|
|
@@ -948,32 +1083,35 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
|
|
|
948
1083
|
<div className="flex items-center gap-2">
|
|
949
1084
|
<span className="font-mono text-xs text-gray-600 px-3 py-1 whitespace-nowrap">
|
|
950
1085
|
{isInitialLoading && !currentLogFile ? (
|
|
951
|
-
<div
|
|
952
|
-
className="h-4 bg-gray-200 rounded animate-pulse"
|
|
953
|
-
style={{ width: "220px" }}
|
|
954
|
-
/>
|
|
1086
|
+
<div className="h-4 bg-gray-200 rounded animate-pulse" style={{ width: "220px" }} />
|
|
955
1087
|
) : currentLogFile ? (
|
|
956
|
-
|
|
1088
|
+
// Show symlink name for current file, basename for others
|
|
1089
|
+
availableLogs.find((log) => log.path === currentLogFile)?.isCurrent ? (
|
|
1090
|
+
"d3k.log"
|
|
1091
|
+
) : (
|
|
1092
|
+
currentLogFile.split("/").pop()
|
|
1093
|
+
)
|
|
957
1094
|
) : (
|
|
958
|
-
"
|
|
1095
|
+
"d3k.log"
|
|
959
1096
|
)}
|
|
960
1097
|
</span>
|
|
961
|
-
{currentLogFile && !isInitialLoading && (
|
|
962
|
-
<button
|
|
963
|
-
onClick={handleRotateLog}
|
|
964
|
-
disabled={isRotatingLog}
|
|
965
|
-
className="px-2 py-1 text-xs bg-orange-100 text-orange-700 hover:bg-orange-200 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
966
|
-
title="Clear logs (rotate current log to archive and start fresh)"
|
|
967
|
-
>
|
|
968
|
-
{isRotatingLog ? "..." : "Clear"}
|
|
969
|
-
</button>
|
|
970
|
-
)}
|
|
971
1098
|
</div>
|
|
972
1099
|
)}
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1100
|
+
|
|
1101
|
+
{/* Entries count */}
|
|
1102
|
+
{logs.length > 0 && <span className="text-sm text-gray-500 hidden sm:inline">{logs.length} entries</span>}
|
|
1103
|
+
|
|
1104
|
+
{/* Clear button - always visible when we have a current log file */}
|
|
1105
|
+
{currentLogFile && !isInitialLoading && (
|
|
1106
|
+
<button
|
|
1107
|
+
type="button"
|
|
1108
|
+
onClick={handleRotateLog}
|
|
1109
|
+
disabled={isRotatingLog}
|
|
1110
|
+
className="px-2 py-1 text-xs bg-orange-100 text-orange-700 hover:bg-orange-200 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
1111
|
+
title="Clear logs (rotate current log to archive and start fresh)"
|
|
1112
|
+
>
|
|
1113
|
+
{isRotatingLog ? "..." : "Clear"}
|
|
1114
|
+
</button>
|
|
977
1115
|
)}
|
|
978
1116
|
</div>
|
|
979
1117
|
|
|
@@ -982,12 +1120,13 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
|
|
|
982
1120
|
{/* Replay Button with Hover Preview */}
|
|
983
1121
|
<div className="relative">
|
|
984
1122
|
<button
|
|
1123
|
+
type="button"
|
|
985
1124
|
onClick={handleReplay}
|
|
986
1125
|
disabled={isReplaying}
|
|
987
1126
|
onMouseEnter={() => {
|
|
988
1127
|
if (!isReplaying) {
|
|
989
|
-
loadReplayPreview()
|
|
990
|
-
setShowReplayPreview(true)
|
|
1128
|
+
loadReplayPreview()
|
|
1129
|
+
setShowReplayPreview(true)
|
|
991
1130
|
}
|
|
992
1131
|
}}
|
|
993
1132
|
onMouseLeave={() => setShowReplayPreview(false)}
|
|
@@ -1004,12 +1143,7 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
|
|
|
1004
1143
|
</>
|
|
1005
1144
|
) : (
|
|
1006
1145
|
<>
|
|
1007
|
-
<svg
|
|
1008
|
-
className="w-4 h-4"
|
|
1009
|
-
fill="none"
|
|
1010
|
-
stroke="currentColor"
|
|
1011
|
-
viewBox="0 0 24 24"
|
|
1012
|
-
>
|
|
1146
|
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1013
1147
|
<path
|
|
1014
1148
|
strokeLinecap="round"
|
|
1015
1149
|
strokeLinejoin="round"
|
|
@@ -1030,13 +1164,11 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
|
|
|
1030
1164
|
</div>
|
|
1031
1165
|
<div className="max-h-60 overflow-y-auto">
|
|
1032
1166
|
{replayEvents.length === 0 ? (
|
|
1033
|
-
<div className="px-3 py-4 text-sm text-gray-500 text-center">
|
|
1034
|
-
No interactions to replay
|
|
1035
|
-
</div>
|
|
1167
|
+
<div className="px-3 py-4 text-sm text-gray-500 text-center">No interactions to replay</div>
|
|
1036
1168
|
) : (
|
|
1037
1169
|
replayEvents.map((event, index) => (
|
|
1038
1170
|
<div
|
|
1039
|
-
key={index}
|
|
1171
|
+
key={`${event.timestamp}-${index}`}
|
|
1040
1172
|
className="px-3 py-2 text-sm hover:bg-gray-50 border-b border-gray-100 last:border-b-0"
|
|
1041
1173
|
>
|
|
1042
1174
|
<div className="flex items-center gap-2">
|
|
@@ -1045,25 +1177,21 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
|
|
|
1045
1177
|
event.type === "CLICK"
|
|
1046
1178
|
? "bg-blue-100 text-blue-800"
|
|
1047
1179
|
: event.type === "SCROLL"
|
|
1048
|
-
|
|
1049
|
-
|
|
1180
|
+
? "bg-green-100 text-green-800"
|
|
1181
|
+
: "bg-gray-100 text-gray-700"
|
|
1050
1182
|
}`}
|
|
1051
1183
|
>
|
|
1052
1184
|
{event.type}
|
|
1053
1185
|
</span>
|
|
1054
1186
|
<span className="text-xs text-gray-500 font-mono">
|
|
1055
|
-
{new Date(
|
|
1056
|
-
event.timestamp
|
|
1057
|
-
).toLocaleTimeString()}
|
|
1187
|
+
{new Date(event.timestamp).toLocaleTimeString()}
|
|
1058
1188
|
</span>
|
|
1059
1189
|
</div>
|
|
1060
1190
|
<div className="mt-1 text-xs text-gray-600 font-mono truncate">
|
|
1061
|
-
{event.type === "CLICK" &&
|
|
1062
|
-
`(${event.x}, ${event.y}) on ${event.target}`}
|
|
1191
|
+
{event.type === "CLICK" && `(${event.x}, ${event.y}) on ${event.target}`}
|
|
1063
1192
|
{event.type === "SCROLL" &&
|
|
1064
1193
|
`${event.direction} ${event.distance}px to (${event.x}, ${event.y})`}
|
|
1065
|
-
{event.type === "KEY" &&
|
|
1066
|
-
`${event.key} in ${event.target}`}
|
|
1194
|
+
{event.type === "KEY" && `${event.key} in ${event.target}`}
|
|
1067
1195
|
</div>
|
|
1068
1196
|
</div>
|
|
1069
1197
|
))
|
|
@@ -1076,15 +1204,11 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
|
|
|
1076
1204
|
{/* Filter Button */}
|
|
1077
1205
|
<div className="relative" ref={filterDropdownRef}>
|
|
1078
1206
|
<button
|
|
1207
|
+
type="button"
|
|
1079
1208
|
onClick={() => setShowFilters(!showFilters)}
|
|
1080
1209
|
className="flex items-center gap-1 px-3 py-1 rounded text-sm font-medium transition-colors whitespace-nowrap bg-gray-100 text-gray-700 hover:bg-gray-200"
|
|
1081
1210
|
>
|
|
1082
|
-
<svg
|
|
1083
|
-
className="w-4 h-4"
|
|
1084
|
-
fill="none"
|
|
1085
|
-
stroke="currentColor"
|
|
1086
|
-
viewBox="0 0 24 24"
|
|
1087
|
-
>
|
|
1211
|
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1088
1212
|
<path
|
|
1089
1213
|
strokeLinecap="round"
|
|
1090
1214
|
strokeLinejoin="round"
|
|
@@ -1096,99 +1220,169 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
|
|
|
1096
1220
|
</button>
|
|
1097
1221
|
{/* Filter Dropdown */}
|
|
1098
1222
|
{showFilters && (
|
|
1099
|
-
<div className="absolute top-full right-0 mt-1 bg-white border border-gray-200 rounded-md shadow-lg z-20 min-w-48">
|
|
1223
|
+
<div className="absolute top-full right-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg z-20 min-w-48">
|
|
1100
1224
|
<div className="py-2">
|
|
1101
|
-
<div className="px-3 py-2 text-xs font-medium text-gray-500 border-b">
|
|
1225
|
+
<div className="px-3 py-2 text-xs font-medium text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
|
|
1102
1226
|
Log Types
|
|
1103
1227
|
</div>
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
).length
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1228
|
+
|
|
1229
|
+
{/* Server Logs */}
|
|
1230
|
+
<label className="flex items-center justify-between px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer">
|
|
1231
|
+
<div className="flex items-center gap-2">
|
|
1232
|
+
<input
|
|
1233
|
+
type="checkbox"
|
|
1234
|
+
checked={filters.server}
|
|
1235
|
+
onChange={(e) =>
|
|
1236
|
+
setFilters((prev) => ({
|
|
1237
|
+
...prev,
|
|
1238
|
+
server: e.target.checked
|
|
1239
|
+
}))
|
|
1240
|
+
}
|
|
1241
|
+
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
1242
|
+
/>
|
|
1243
|
+
<span className="text-gray-900 dark:text-gray-100">Server</span>
|
|
1244
|
+
</div>
|
|
1245
|
+
<span className="text-xs text-gray-400">
|
|
1246
|
+
{logs.filter((l) => l.source === "SERVER").length}
|
|
1247
|
+
</span>
|
|
1248
|
+
</label>
|
|
1249
|
+
|
|
1250
|
+
{/* Browser Logs */}
|
|
1251
|
+
<label className="flex items-center justify-between px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer">
|
|
1252
|
+
<div className="flex items-center gap-2">
|
|
1253
|
+
<input
|
|
1254
|
+
type="checkbox"
|
|
1255
|
+
checked={filters.browser}
|
|
1256
|
+
onChange={(e) =>
|
|
1257
|
+
setFilters((prev) => ({
|
|
1258
|
+
...prev,
|
|
1259
|
+
browser: e.target.checked
|
|
1260
|
+
}))
|
|
1261
|
+
}
|
|
1262
|
+
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
1263
|
+
/>
|
|
1264
|
+
<span className="text-gray-900 dark:text-gray-100">Browser</span>
|
|
1265
|
+
</div>
|
|
1266
|
+
<span className="text-xs text-gray-400">
|
|
1267
|
+
{logs.filter((l) => l.source === "BROWSER").length}
|
|
1268
|
+
</span>
|
|
1269
|
+
</label>
|
|
1270
|
+
|
|
1271
|
+
{/* User Agent Sub-filters */}
|
|
1272
|
+
{availableUserAgents.length > 1 && filters.browser && (
|
|
1273
|
+
<div className="ml-6 border-l border-gray-200 dark:border-gray-600 pl-2">
|
|
1274
|
+
{availableUserAgents.map((ua) => {
|
|
1275
|
+
const shortUA = ua.includes("Chrome")
|
|
1276
|
+
? "Chrome"
|
|
1277
|
+
: ua.includes("Firefox")
|
|
1278
|
+
? "Firefox"
|
|
1279
|
+
: ua.includes("Safari")
|
|
1280
|
+
? "Safari"
|
|
1281
|
+
: ua.includes("Edge")
|
|
1282
|
+
? "Edge"
|
|
1283
|
+
: "Browser"
|
|
1284
|
+
return (
|
|
1285
|
+
<label
|
|
1286
|
+
key={ua}
|
|
1287
|
+
className="flex items-center justify-between px-2 py-1 text-xs hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
|
|
1288
|
+
>
|
|
1289
|
+
<div className="flex items-center gap-2">
|
|
1290
|
+
<input
|
|
1291
|
+
type="checkbox"
|
|
1292
|
+
checked={userAgentFilters[ua] !== false}
|
|
1293
|
+
onChange={(e) =>
|
|
1294
|
+
setUserAgentFilters((prev) => ({
|
|
1295
|
+
...prev,
|
|
1296
|
+
[ua]: e.target.checked
|
|
1297
|
+
}))
|
|
1298
|
+
}
|
|
1299
|
+
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 w-3 h-3"
|
|
1300
|
+
/>
|
|
1301
|
+
<span className="text-gray-700 dark:text-gray-300">{shortUA}</span>
|
|
1302
|
+
</div>
|
|
1303
|
+
<span className="text-xs text-gray-400">
|
|
1304
|
+
{logs.filter((l) => l.source === "BROWSER" && l.userAgent === ua).length}
|
|
1305
|
+
</span>
|
|
1306
|
+
</label>
|
|
1307
|
+
)
|
|
1308
|
+
})}
|
|
1309
|
+
</div>
|
|
1310
|
+
)}
|
|
1311
|
+
|
|
1312
|
+
{/* Interaction Logs */}
|
|
1313
|
+
<label className="flex items-center justify-between px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer">
|
|
1314
|
+
<div className="flex items-center gap-2">
|
|
1315
|
+
<input
|
|
1316
|
+
type="checkbox"
|
|
1317
|
+
checked={filters.interaction}
|
|
1318
|
+
onChange={(e) =>
|
|
1319
|
+
setFilters((prev) => ({
|
|
1320
|
+
...prev,
|
|
1321
|
+
interaction: e.target.checked
|
|
1322
|
+
}))
|
|
1323
|
+
}
|
|
1324
|
+
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
1325
|
+
/>
|
|
1326
|
+
<span className="text-gray-900 dark:text-gray-100">Interaction</span>
|
|
1327
|
+
</div>
|
|
1328
|
+
<span className="text-xs text-gray-400">
|
|
1329
|
+
{logs.filter((l) => l.message.includes("[INTERACTION]")).length}
|
|
1330
|
+
</span>
|
|
1331
|
+
</label>
|
|
1332
|
+
|
|
1333
|
+
{/* Screenshot Logs */}
|
|
1334
|
+
<label className="flex items-center justify-between px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer">
|
|
1335
|
+
<div className="flex items-center gap-2">
|
|
1336
|
+
<input
|
|
1337
|
+
type="checkbox"
|
|
1338
|
+
checked={filters.screenshot}
|
|
1339
|
+
onChange={(e) =>
|
|
1340
|
+
setFilters((prev) => ({
|
|
1341
|
+
...prev,
|
|
1342
|
+
screenshot: e.target.checked
|
|
1343
|
+
}))
|
|
1344
|
+
}
|
|
1345
|
+
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
1346
|
+
/>
|
|
1347
|
+
<span className="text-gray-900 dark:text-gray-100">Screenshot</span>
|
|
1348
|
+
</div>
|
|
1349
|
+
<span className="text-xs text-gray-400">
|
|
1350
|
+
{logs.filter((l) => l.message.includes("[SCREENSHOT]")).length}
|
|
1351
|
+
</span>
|
|
1352
|
+
</label>
|
|
1153
1353
|
</div>
|
|
1154
1354
|
</div>
|
|
1155
1355
|
)}
|
|
1156
1356
|
</div>
|
|
1157
1357
|
<div className="flex items-center bg-gray-100 rounded-md p-1">
|
|
1158
1358
|
<button
|
|
1359
|
+
type="button"
|
|
1159
1360
|
onClick={() => {
|
|
1160
|
-
const currentFile = searchParams.get("file")
|
|
1361
|
+
const currentFile = searchParams.get("file")
|
|
1161
1362
|
if (currentFile) {
|
|
1162
|
-
router.push(
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
)}&mode=head`
|
|
1166
|
-
);
|
|
1363
|
+
router.push(`/logs?file=${encodeURIComponent(currentFile)}&mode=head`)
|
|
1364
|
+
} else {
|
|
1365
|
+
router.push("/logs?mode=head")
|
|
1167
1366
|
}
|
|
1168
1367
|
}}
|
|
1169
1368
|
className={`px-2 sm:px-3 py-1 rounded text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
|
|
1170
|
-
mode === "head"
|
|
1171
|
-
? "bg-white text-gray-900 shadow-sm"
|
|
1172
|
-
: "text-gray-600 hover:text-gray-900"
|
|
1369
|
+
mode === "head" ? "bg-white text-gray-900 shadow-sm" : "text-gray-600 hover:text-gray-900"
|
|
1173
1370
|
}`}
|
|
1174
1371
|
>
|
|
1175
1372
|
Head
|
|
1176
1373
|
</button>
|
|
1177
1374
|
<button
|
|
1375
|
+
type="button"
|
|
1178
1376
|
onClick={() => {
|
|
1179
|
-
const currentFile = searchParams.get("file")
|
|
1377
|
+
const currentFile = searchParams.get("file")
|
|
1180
1378
|
if (currentFile) {
|
|
1181
|
-
router.push(
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
)}&mode=tail`
|
|
1185
|
-
);
|
|
1379
|
+
router.push(`/logs?file=${encodeURIComponent(currentFile)}&mode=tail`)
|
|
1380
|
+
} else {
|
|
1381
|
+
router.push("/logs?mode=tail")
|
|
1186
1382
|
}
|
|
1187
1383
|
}}
|
|
1188
1384
|
className={`px-2 sm:px-3 py-1 rounded text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
|
|
1189
|
-
mode === "tail"
|
|
1190
|
-
? "bg-white text-gray-900 shadow-sm"
|
|
1191
|
-
: "text-gray-600 hover:text-gray-900"
|
|
1385
|
+
mode === "tail" ? "bg-white text-gray-900 shadow-sm" : "text-gray-600 hover:text-gray-900"
|
|
1192
1386
|
}`}
|
|
1193
1387
|
>
|
|
1194
1388
|
Tail
|
|
@@ -1196,20 +1390,14 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
|
|
|
1196
1390
|
</div>
|
|
1197
1391
|
{/* Dark Mode Toggle - moved to last item */}
|
|
1198
1392
|
<button
|
|
1393
|
+
type="button"
|
|
1199
1394
|
onClick={() => setDarkMode(!darkMode)}
|
|
1200
1395
|
className="p-2 rounded-md text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ml-2"
|
|
1201
|
-
title={
|
|
1202
|
-
darkMode ? "Switch to light mode" : "Switch to dark mode"
|
|
1203
|
-
}
|
|
1396
|
+
title={darkMode ? "Switch to light mode" : "Switch to dark mode"}
|
|
1204
1397
|
>
|
|
1205
1398
|
{darkMode ? (
|
|
1206
1399
|
// Sun icon for light mode
|
|
1207
|
-
<svg
|
|
1208
|
-
className="w-4 h-4"
|
|
1209
|
-
fill="none"
|
|
1210
|
-
stroke="currentColor"
|
|
1211
|
-
viewBox="0 0 24 24"
|
|
1212
|
-
>
|
|
1400
|
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1213
1401
|
<path
|
|
1214
1402
|
strokeLinecap="round"
|
|
1215
1403
|
strokeLinejoin="round"
|
|
@@ -1219,12 +1407,7 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
|
|
|
1219
1407
|
</svg>
|
|
1220
1408
|
) : (
|
|
1221
1409
|
// Moon icon for dark mode
|
|
1222
|
-
<svg
|
|
1223
|
-
className="w-4 h-4"
|
|
1224
|
-
fill="none"
|
|
1225
|
-
stroke="currentColor"
|
|
1226
|
-
viewBox="0 0 24 24"
|
|
1227
|
-
>
|
|
1410
|
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1228
1411
|
<path
|
|
1229
1412
|
strokeLinecap="round"
|
|
1230
1413
|
strokeLinejoin="round"
|
|
@@ -1241,11 +1424,7 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
|
|
|
1241
1424
|
|
|
1242
1425
|
{/* Content Area - Fills remaining space */}
|
|
1243
1426
|
<div className="flex-1 overflow-hidden">
|
|
1244
|
-
<div
|
|
1245
|
-
ref={containerRef}
|
|
1246
|
-
className="max-w-7xl mx-auto px-4 py-6 h-full overflow-y-auto"
|
|
1247
|
-
onScroll={handleScroll}
|
|
1248
|
-
>
|
|
1427
|
+
<div ref={containerRef} className="max-w-7xl mx-auto px-4 py-6 h-full overflow-y-auto" onScroll={handleScroll}>
|
|
1249
1428
|
{isInitialLoading ? (
|
|
1250
1429
|
<div className="text-center py-12">
|
|
1251
1430
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|
@@ -1253,18 +1432,14 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
|
|
|
1253
1432
|
</div>
|
|
1254
1433
|
) : logs.length === 0 ? (
|
|
1255
1434
|
<div className="text-center py-12">
|
|
1256
|
-
<div className="text-gray-400 dark:text-gray-500 text-lg">
|
|
1257
|
-
📝 No logs yet
|
|
1258
|
-
</div>
|
|
1435
|
+
<div className="text-gray-400 dark:text-gray-500 text-lg">📝 No logs yet</div>
|
|
1259
1436
|
<div className="text-gray-500 dark:text-gray-400 text-sm mt-2">
|
|
1260
1437
|
Logs will appear here as your development server runs
|
|
1261
1438
|
</div>
|
|
1262
1439
|
</div>
|
|
1263
1440
|
) : filteredLogs.length === 0 ? (
|
|
1264
1441
|
<div className="text-center py-12">
|
|
1265
|
-
<div className="text-gray-400 dark:text-gray-500 text-lg">
|
|
1266
|
-
🔍 No logs match current filters
|
|
1267
|
-
</div>
|
|
1442
|
+
<div className="text-gray-400 dark:text-gray-500 text-lg">🔍 No logs match current filters</div>
|
|
1268
1443
|
<div className="text-gray-500 dark:text-gray-400 text-sm mt-2">
|
|
1269
1444
|
Try adjusting your filter settings to see more logs
|
|
1270
1445
|
</div>
|
|
@@ -1272,7 +1447,7 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
|
|
|
1272
1447
|
) : (
|
|
1273
1448
|
<div className="space-y-1 pb-4">
|
|
1274
1449
|
{filteredLogs.map((entry, index) => (
|
|
1275
|
-
<LogEntryComponent key={index} entry={entry} />
|
|
1450
|
+
<LogEntryComponent key={`${entry.timestamp}-${index}`} entry={entry} />
|
|
1276
1451
|
))}
|
|
1277
1452
|
<div ref={bottomRef} />
|
|
1278
1453
|
</div>
|
|
@@ -1284,15 +1459,7 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
|
|
|
1284
1459
|
<div className="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 flex-none">
|
|
1285
1460
|
<div className="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
|
|
1286
1461
|
<div className="flex items-center gap-3">
|
|
1287
|
-
{
|
|
1288
|
-
<div className="flex items-center gap-1">
|
|
1289
|
-
<div className="w-3 h-3 border border-gray-300 border-t-blue-500 rounded-full animate-spin"></div>
|
|
1290
|
-
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
1291
|
-
Loading...
|
|
1292
|
-
</span>
|
|
1293
|
-
</div>
|
|
1294
|
-
)}
|
|
1295
|
-
{!isLoadingNew && lastFetched && (
|
|
1462
|
+
{lastFetched && (
|
|
1296
1463
|
<span className="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
|
1297
1464
|
Last updated {lastFetched.toLocaleTimeString()}
|
|
1298
1465
|
</span>
|
|
@@ -1314,9 +1481,7 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
|
|
|
1314
1481
|
{/* Live indicator when at bottom */}
|
|
1315
1482
|
<div
|
|
1316
1483
|
className={`flex items-center gap-1 text-green-600 ${
|
|
1317
|
-
mode === "tail" && isAtBottom
|
|
1318
|
-
? "visible"
|
|
1319
|
-
: "invisible"
|
|
1484
|
+
mode === "tail" && isAtBottom ? "visible" : "invisible"
|
|
1320
1485
|
}`}
|
|
1321
1486
|
>
|
|
1322
1487
|
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
|
@@ -1325,13 +1490,10 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
|
|
|
1325
1490
|
|
|
1326
1491
|
{/* Scroll to bottom button when not at bottom */}
|
|
1327
1492
|
<button
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
}
|
|
1493
|
+
type="button"
|
|
1494
|
+
onClick={() => bottomRef.current?.scrollIntoView({ behavior: "smooth" })}
|
|
1331
1495
|
className={`absolute top-0 right-0 flex items-center gap-1 px-2 py-0.5 bg-blue-600 text-white rounded text-xs hover:bg-blue-700 whitespace-nowrap ${
|
|
1332
|
-
mode === "tail" && !isAtBottom
|
|
1333
|
-
? "visible"
|
|
1334
|
-
: "invisible"
|
|
1496
|
+
mode === "tail" && !isAtBottom ? "visible" : "invisible"
|
|
1335
1497
|
}`}
|
|
1336
1498
|
>
|
|
1337
1499
|
↓ Live
|
|
@@ -1340,5 +1502,5 @@ export default function LogsClient({ version, initialData }: LogsClientProps) {
|
|
|
1340
1502
|
</div>
|
|
1341
1503
|
</div>
|
|
1342
1504
|
</div>
|
|
1343
|
-
)
|
|
1505
|
+
)
|
|
1344
1506
|
}
|