dev3000 0.0.22 → 0.0.26
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/dist/cdp-monitor.d.ts +39 -0
- package/dist/cdp-monitor.d.ts.map +1 -0
- package/dist/cdp-monitor.js +697 -0
- package/dist/cdp-monitor.js.map +1 -0
- package/dist/cli.js +16 -4
- package/dist/cli.js.map +1 -1
- package/dist/dev-environment.d.ts +4 -17
- package/dist/dev-environment.d.ts.map +1 -1
- package/dist/dev-environment.js +74 -571
- package/dist/dev-environment.js.map +1 -1
- package/mcp-server/app/api/replay/route.ts +412 -0
- package/mcp-server/app/logs/LogsClient.tsx +308 -39
- package/mcp-server/app/replay/ReplayClient.tsx +274 -0
- package/mcp-server/app/replay/page.tsx +5 -0
- package/package.json +8 -5
|
@@ -66,8 +66,57 @@ function parseLogLine(line: string): LogEntry | null {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
function LogEntryComponent({ entry }: { entry: LogEntry }) {
|
|
69
|
+
// Parse log type from message patterns
|
|
70
|
+
const parseLogType = (message: string) => {
|
|
71
|
+
if (message.includes('[INTERACTION]')) return { type: 'INTERACTION', color: 'bg-purple-50 border-purple-200', tag: 'bg-purple-100 text-purple-800' };
|
|
72
|
+
if (message.includes('[CONSOLE ERROR]')) return { type: 'ERROR', color: 'bg-red-50 border-red-200', tag: 'bg-red-100 text-red-800' };
|
|
73
|
+
if (message.includes('[CONSOLE WARN]')) return { type: 'WARNING', color: 'bg-yellow-50 border-yellow-200', tag: 'bg-yellow-100 text-yellow-800' };
|
|
74
|
+
if (message.includes('[SCREENSHOT]')) return { type: 'SCREENSHOT', color: 'bg-blue-50 border-blue-200', tag: 'bg-blue-100 text-blue-800' };
|
|
75
|
+
if (message.includes('[NAVIGATION]')) return { type: 'NAVIGATION', color: 'bg-indigo-50 border-indigo-200', tag: 'bg-indigo-100 text-indigo-800' };
|
|
76
|
+
if (message.includes('[NETWORK ERROR]')) return { type: 'NETWORK', color: 'bg-red-50 border-red-200', tag: 'bg-red-100 text-red-800' };
|
|
77
|
+
if (message.includes('[NETWORK REQUEST]')) return { type: 'NETWORK', color: 'bg-gray-50 border-gray-200', tag: 'bg-gray-100 text-gray-700' };
|
|
78
|
+
if (message.includes('[PAGE ERROR]')) return { type: 'ERROR', color: 'bg-red-50 border-red-200', tag: 'bg-red-100 text-red-800' };
|
|
79
|
+
return { type: 'DEFAULT', color: 'border-gray-200', tag: 'bg-gray-100 text-gray-700' };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const logTypeInfo = parseLogType(entry.message);
|
|
83
|
+
|
|
84
|
+
// Extract and highlight type tags
|
|
85
|
+
const renderMessage = (message: string) => {
|
|
86
|
+
const typeTagRegex = /\[([A-Z\s]+)\]/g;
|
|
87
|
+
const parts = [];
|
|
88
|
+
let lastIndex = 0;
|
|
89
|
+
let match;
|
|
90
|
+
|
|
91
|
+
while ((match = typeTagRegex.exec(message)) !== null) {
|
|
92
|
+
// Add text before the tag
|
|
93
|
+
if (match.index > lastIndex) {
|
|
94
|
+
parts.push(message.slice(lastIndex, match.index));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Add the tag with styling
|
|
98
|
+
parts.push(
|
|
99
|
+
<span
|
|
100
|
+
key={match.index}
|
|
101
|
+
className={`inline-block px-1.5 py-0.5 rounded text-xs font-medium ${logTypeInfo.tag} mr-1`}
|
|
102
|
+
>
|
|
103
|
+
{match[1]}
|
|
104
|
+
</span>
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
lastIndex = match.index + match[0].length;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Add remaining text
|
|
111
|
+
if (lastIndex < message.length) {
|
|
112
|
+
parts.push(message.slice(lastIndex));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return parts.length > 0 ? parts : message;
|
|
116
|
+
};
|
|
117
|
+
|
|
69
118
|
return (
|
|
70
|
-
<div className=
|
|
119
|
+
<div className={`border-l-4 ${logTypeInfo.color} pl-4 py-2`}>
|
|
71
120
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
|
72
121
|
<span className="font-mono">
|
|
73
122
|
{new Date(entry.timestamp).toLocaleTimeString()}
|
|
@@ -79,7 +128,7 @@ function LogEntryComponent({ entry }: { entry: LogEntry }) {
|
|
|
79
128
|
</span>
|
|
80
129
|
</div>
|
|
81
130
|
<div className="mt-1 font-mono text-sm whitespace-pre-wrap break-words overflow-wrap-anywhere">
|
|
82
|
-
{entry.message}
|
|
131
|
+
{renderMessage(entry.message)}
|
|
83
132
|
</div>
|
|
84
133
|
{entry.screenshot && (
|
|
85
134
|
<div className="mt-2">
|
|
@@ -111,10 +160,21 @@ export default function LogsClient({ version }: LogsClientProps) {
|
|
|
111
160
|
const [currentLogFile, setCurrentLogFile] = useState<string>('');
|
|
112
161
|
const [projectName, setProjectName] = useState<string>('');
|
|
113
162
|
const [showLogSelector, setShowLogSelector] = useState(false);
|
|
163
|
+
const [isReplaying, setIsReplaying] = useState(false);
|
|
164
|
+
const [showFilters, setShowFilters] = useState(false);
|
|
165
|
+
const [showReplayPreview, setShowReplayPreview] = useState(false);
|
|
166
|
+
const [replayEvents, setReplayEvents] = useState<any[]>([]);
|
|
167
|
+
const [filters, setFilters] = useState({
|
|
168
|
+
browser: true,
|
|
169
|
+
server: true,
|
|
170
|
+
interaction: true,
|
|
171
|
+
screenshot: true
|
|
172
|
+
});
|
|
114
173
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|
115
174
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
116
175
|
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
117
176
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
177
|
+
const filterDropdownRef = useRef<HTMLDivElement>(null);
|
|
118
178
|
|
|
119
179
|
const loadAvailableLogs = async () => {
|
|
120
180
|
try {
|
|
@@ -236,19 +296,22 @@ export default function LogsClient({ version }: LogsClientProps) {
|
|
|
236
296
|
};
|
|
237
297
|
}, []);
|
|
238
298
|
|
|
239
|
-
// Close
|
|
299
|
+
// Close dropdowns when clicking outside
|
|
240
300
|
useEffect(() => {
|
|
241
301
|
const handleClickOutside = (event: MouseEvent) => {
|
|
242
302
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
243
303
|
setShowLogSelector(false);
|
|
244
304
|
}
|
|
305
|
+
if (filterDropdownRef.current && !filterDropdownRef.current.contains(event.target as Node)) {
|
|
306
|
+
setShowFilters(false);
|
|
307
|
+
}
|
|
245
308
|
};
|
|
246
309
|
|
|
247
|
-
if (showLogSelector) {
|
|
310
|
+
if (showLogSelector || showFilters) {
|
|
248
311
|
document.addEventListener('mousedown', handleClickOutside);
|
|
249
312
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
250
313
|
}
|
|
251
|
-
}, [showLogSelector]);
|
|
314
|
+
}, [showLogSelector, showFilters]);
|
|
252
315
|
|
|
253
316
|
const handleScroll = () => {
|
|
254
317
|
if (containerRef.current) {
|
|
@@ -258,13 +321,87 @@ export default function LogsClient({ version }: LogsClientProps) {
|
|
|
258
321
|
}
|
|
259
322
|
};
|
|
260
323
|
|
|
324
|
+
const handleReplay = async () => {
|
|
325
|
+
if (isReplaying) return;
|
|
326
|
+
|
|
327
|
+
setIsReplaying(true);
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
// Get replay data from logs
|
|
331
|
+
const response = await fetch('/api/replay?action=parse');
|
|
332
|
+
if (!response.ok) {
|
|
333
|
+
throw new Error('Failed to parse replay data');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const replayData = await response.json();
|
|
337
|
+
|
|
338
|
+
if (replayData.interactions.length === 0) {
|
|
339
|
+
alert('No user interactions found in logs to replay');
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Generate CDP commands for replay
|
|
344
|
+
const response2 = await fetch('/api/replay', {
|
|
345
|
+
method: 'POST',
|
|
346
|
+
headers: { 'Content-Type': 'application/json' },
|
|
347
|
+
body: JSON.stringify({
|
|
348
|
+
action: 'execute',
|
|
349
|
+
replayData: replayData,
|
|
350
|
+
speed: 2
|
|
351
|
+
})
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const result = await response2.json();
|
|
355
|
+
|
|
356
|
+
if (result.success) {
|
|
357
|
+
console.log('Replay executed successfully:', result);
|
|
358
|
+
alert(`Replay completed! Executed ${result.totalCommands} commands.`);
|
|
359
|
+
} else {
|
|
360
|
+
console.log('CDP execution failed, showing commands:', result);
|
|
361
|
+
alert(`CDP execution not available. Generated ${result.commands?.length || 0} commands. Check console for details.`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
} catch (error) {
|
|
365
|
+
console.error('Replay error:', error);
|
|
366
|
+
alert('Failed to start replay: ' + (error instanceof Error ? error.message : 'Unknown error'));
|
|
367
|
+
} finally {
|
|
368
|
+
setIsReplaying(false);
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const loadReplayPreview = async () => {
|
|
373
|
+
try {
|
|
374
|
+
const response = await fetch('/api/replay?action=parse');
|
|
375
|
+
if (response.ok) {
|
|
376
|
+
const data = await response.json();
|
|
377
|
+
setReplayEvents(data.interactions || []);
|
|
378
|
+
}
|
|
379
|
+
} catch (error) {
|
|
380
|
+
console.error('Error loading replay preview:', error);
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
|
|
261
384
|
const filteredLogs = useMemo(() => {
|
|
262
|
-
return logs
|
|
263
|
-
|
|
385
|
+
return logs.filter(entry => {
|
|
386
|
+
// Check specific message types first (these override source filtering)
|
|
387
|
+
const isInteraction = entry.message.includes('[INTERACTION]');
|
|
388
|
+
const isScreenshot = entry.message.includes('[SCREENSHOT]');
|
|
389
|
+
|
|
390
|
+
if (isInteraction) return filters.interaction;
|
|
391
|
+
if (isScreenshot) return filters.screenshot;
|
|
392
|
+
|
|
393
|
+
// For other logs, filter by source
|
|
394
|
+
if (entry.source === 'SERVER') return filters.server;
|
|
395
|
+
if (entry.source === 'BROWSER') return filters.browser;
|
|
396
|
+
|
|
397
|
+
return true;
|
|
398
|
+
});
|
|
399
|
+
}, [logs, filters]);
|
|
264
400
|
|
|
265
401
|
return (
|
|
266
|
-
<div className="
|
|
267
|
-
|
|
402
|
+
<div className="h-screen bg-gray-50 flex flex-col">
|
|
403
|
+
{/* Header - Fixed */}
|
|
404
|
+
<div className="bg-white shadow-sm border-b flex-none z-10">
|
|
268
405
|
<div className="max-w-7xl mx-auto px-4 py-3">
|
|
269
406
|
<div className="flex items-center justify-between">
|
|
270
407
|
<div className="flex items-center gap-2 sm:gap-4">
|
|
@@ -347,8 +484,129 @@ export default function LogsClient({ version }: LogsClientProps) {
|
|
|
347
484
|
)}
|
|
348
485
|
</div>
|
|
349
486
|
|
|
350
|
-
{/* Mode Toggle */}
|
|
351
|
-
<div className="flex items-center
|
|
487
|
+
{/* Mode Toggle with Replay Button */}
|
|
488
|
+
<div className="flex items-center gap-2">
|
|
489
|
+
{/* Replay Button with Hover Preview */}
|
|
490
|
+
<div className="relative">
|
|
491
|
+
<button
|
|
492
|
+
onClick={handleReplay}
|
|
493
|
+
disabled={isReplaying}
|
|
494
|
+
onMouseEnter={() => {
|
|
495
|
+
if (!isReplaying) {
|
|
496
|
+
loadReplayPreview();
|
|
497
|
+
setShowReplayPreview(true);
|
|
498
|
+
}
|
|
499
|
+
}}
|
|
500
|
+
onMouseLeave={() => setShowReplayPreview(false)}
|
|
501
|
+
className={`flex items-center gap-1 px-3 py-1 rounded text-sm font-medium transition-colors whitespace-nowrap ${
|
|
502
|
+
isReplaying
|
|
503
|
+
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
|
504
|
+
: 'bg-purple-100 text-purple-800 hover:bg-purple-200'
|
|
505
|
+
}`}
|
|
506
|
+
>
|
|
507
|
+
{isReplaying ? (
|
|
508
|
+
<>
|
|
509
|
+
<div className="w-3 h-3 border border-purple-300 border-t-purple-600 rounded-full animate-spin"></div>
|
|
510
|
+
Replaying...
|
|
511
|
+
</>
|
|
512
|
+
) : (
|
|
513
|
+
<>
|
|
514
|
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
515
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1m4 0h1m-6 4h1m4 0h1m6-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M7 7V4a1 1 0 011-1h4a1 1 0 011 1v3m-9 4h16m-5 4v1a1 1 0 01-1 1H8a1 1 0 01-1-1v-1m8 0V9a1 1 0 00-1-1H8a1 1 0 00-1 1v8.001" />
|
|
516
|
+
</svg>
|
|
517
|
+
Replay
|
|
518
|
+
</>
|
|
519
|
+
)}
|
|
520
|
+
</button>
|
|
521
|
+
|
|
522
|
+
{/* Replay Preview Dropdown */}
|
|
523
|
+
{showReplayPreview && !isReplaying && (
|
|
524
|
+
<div className="absolute top-full left-0 mt-1 bg-white border border-gray-200 rounded-md shadow-lg z-30 w-80">
|
|
525
|
+
<div className="py-2">
|
|
526
|
+
<div className="px-3 py-2 text-xs font-medium text-gray-500 border-b">
|
|
527
|
+
Replay Events ({replayEvents.length})
|
|
528
|
+
</div>
|
|
529
|
+
<div className="max-h-60 overflow-y-auto">
|
|
530
|
+
{replayEvents.length === 0 ? (
|
|
531
|
+
<div className="px-3 py-4 text-sm text-gray-500 text-center">
|
|
532
|
+
No interactions to replay
|
|
533
|
+
</div>
|
|
534
|
+
) : (
|
|
535
|
+
replayEvents.map((event, index) => (
|
|
536
|
+
<div key={index} className="px-3 py-2 text-sm hover:bg-gray-50 border-b border-gray-100 last:border-b-0">
|
|
537
|
+
<div className="flex items-center gap-2">
|
|
538
|
+
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${
|
|
539
|
+
event.type === 'CLICK' ? 'bg-blue-100 text-blue-800' :
|
|
540
|
+
event.type === 'SCROLL' ? 'bg-green-100 text-green-800' :
|
|
541
|
+
'bg-gray-100 text-gray-700'
|
|
542
|
+
}`}>
|
|
543
|
+
{event.type}
|
|
544
|
+
</span>
|
|
545
|
+
<span className="text-xs text-gray-500 font-mono">
|
|
546
|
+
{new Date(event.timestamp).toLocaleTimeString()}
|
|
547
|
+
</span>
|
|
548
|
+
</div>
|
|
549
|
+
<div className="mt-1 text-xs text-gray-600 font-mono truncate">
|
|
550
|
+
{event.type === 'CLICK' && `(${event.x}, ${event.y}) on ${event.target}`}
|
|
551
|
+
{event.type === 'SCROLL' && `${event.direction} ${event.distance}px to (${event.x}, ${event.y})`}
|
|
552
|
+
{event.type === 'KEY' && `${event.key} in ${event.target}`}
|
|
553
|
+
</div>
|
|
554
|
+
</div>
|
|
555
|
+
))
|
|
556
|
+
)}
|
|
557
|
+
</div>
|
|
558
|
+
</div>
|
|
559
|
+
</div>
|
|
560
|
+
)}
|
|
561
|
+
</div>
|
|
562
|
+
|
|
563
|
+
{/* Filter Button */}
|
|
564
|
+
<div className="relative" ref={filterDropdownRef}>
|
|
565
|
+
<button
|
|
566
|
+
onClick={() => setShowFilters(!showFilters)}
|
|
567
|
+
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"
|
|
568
|
+
>
|
|
569
|
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
570
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
|
571
|
+
</svg>
|
|
572
|
+
Filter
|
|
573
|
+
</button>
|
|
574
|
+
|
|
575
|
+
{/* Filter Dropdown */}
|
|
576
|
+
{showFilters && (
|
|
577
|
+
<div className="absolute top-full right-0 mt-1 bg-white border border-gray-200 rounded-md shadow-lg z-20 min-w-48">
|
|
578
|
+
<div className="py-2">
|
|
579
|
+
<div className="px-3 py-2 text-xs font-medium text-gray-500 border-b">
|
|
580
|
+
Log Types
|
|
581
|
+
</div>
|
|
582
|
+
{[
|
|
583
|
+
{ key: 'server', label: 'Server', count: logs.filter(l => l.source === 'SERVER').length },
|
|
584
|
+
{ key: 'browser', label: 'Browser', count: logs.filter(l => l.source === 'BROWSER').length },
|
|
585
|
+
{ key: 'interaction', label: 'Interaction', count: logs.filter(l => l.message.includes('[INTERACTION]')).length },
|
|
586
|
+
{ key: 'screenshot', label: 'Screenshot', count: logs.filter(l => l.message.includes('[SCREENSHOT]')).length }
|
|
587
|
+
].map(({ key, label, count }) => (
|
|
588
|
+
<label
|
|
589
|
+
key={key}
|
|
590
|
+
className="flex items-center justify-between px-3 py-2 text-sm hover:bg-gray-50 cursor-pointer"
|
|
591
|
+
>
|
|
592
|
+
<div className="flex items-center gap-2">
|
|
593
|
+
<input
|
|
594
|
+
type="checkbox"
|
|
595
|
+
checked={filters[key as keyof typeof filters]}
|
|
596
|
+
onChange={(e) => setFilters(prev => ({ ...prev, [key]: e.target.checked }))}
|
|
597
|
+
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
598
|
+
/>
|
|
599
|
+
<span>{label}</span>
|
|
600
|
+
</div>
|
|
601
|
+
<span className="text-xs text-gray-400">{count}</span>
|
|
602
|
+
</label>
|
|
603
|
+
))}
|
|
604
|
+
</div>
|
|
605
|
+
</div>
|
|
606
|
+
)}
|
|
607
|
+
</div>
|
|
608
|
+
|
|
609
|
+
<div className="flex items-center bg-gray-100 rounded-md p-1">
|
|
352
610
|
<button
|
|
353
611
|
onClick={() => {
|
|
354
612
|
setMode('head');
|
|
@@ -373,40 +631,51 @@ export default function LogsClient({ version }: LogsClientProps) {
|
|
|
373
631
|
>
|
|
374
632
|
Tail
|
|
375
633
|
</button>
|
|
634
|
+
</div>
|
|
376
635
|
</div>
|
|
377
636
|
</div>
|
|
378
637
|
</div>
|
|
379
638
|
</div>
|
|
380
639
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
<div className="text-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
<div className="text-center py-12">
|
|
393
|
-
<div className="text-gray-400 text-lg">📝 No logs yet</div>
|
|
394
|
-
<div className="text-gray-500 text-sm mt-2">
|
|
395
|
-
Logs will appear here as your development server runs
|
|
640
|
+
{/* Content Area - Fills remaining space */}
|
|
641
|
+
<div className="flex-1 overflow-hidden">
|
|
642
|
+
<div
|
|
643
|
+
ref={containerRef}
|
|
644
|
+
className="max-w-7xl mx-auto px-4 py-6 h-full overflow-y-auto"
|
|
645
|
+
onScroll={handleScroll}
|
|
646
|
+
>
|
|
647
|
+
{isInitialLoading ? (
|
|
648
|
+
<div className="text-center py-12">
|
|
649
|
+
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|
650
|
+
<div className="text-gray-500 text-sm mt-4">Loading logs...</div>
|
|
396
651
|
</div>
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
652
|
+
) : logs.length === 0 ? (
|
|
653
|
+
<div className="text-center py-12">
|
|
654
|
+
<div className="text-gray-400 text-lg">📝 No logs yet</div>
|
|
655
|
+
<div className="text-gray-500 text-sm mt-2">
|
|
656
|
+
Logs will appear here as your development server runs
|
|
657
|
+
</div>
|
|
658
|
+
</div>
|
|
659
|
+
) : filteredLogs.length === 0 ? (
|
|
660
|
+
<div className="text-center py-12">
|
|
661
|
+
<div className="text-gray-400 text-lg">🔍 No logs match current filters</div>
|
|
662
|
+
<div className="text-gray-500 text-sm mt-2">
|
|
663
|
+
Try adjusting your filter settings to see more logs
|
|
664
|
+
</div>
|
|
665
|
+
</div>
|
|
666
|
+
) : (
|
|
667
|
+
<div className="space-y-1 pb-4">
|
|
668
|
+
{filteredLogs.map((entry, index) => (
|
|
669
|
+
<LogEntryComponent key={index} entry={entry} />
|
|
670
|
+
))}
|
|
671
|
+
<div ref={bottomRef} />
|
|
672
|
+
</div>
|
|
673
|
+
)}
|
|
674
|
+
</div>
|
|
406
675
|
</div>
|
|
407
676
|
|
|
408
|
-
{/* Footer
|
|
409
|
-
<div className="border-t border-gray-200 bg-gray-50
|
|
677
|
+
{/* Footer - Fixed */}
|
|
678
|
+
<div className="border-t border-gray-200 bg-gray-50 flex-none">
|
|
410
679
|
<div className="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
|
|
411
680
|
<div className="flex items-center gap-3">
|
|
412
681
|
{isLoadingNew && (
|
|
@@ -447,11 +716,11 @@ export default function LogsClient({ version }: LogsClientProps) {
|
|
|
447
716
|
{/* Scroll to bottom button when not at bottom */}
|
|
448
717
|
<button
|
|
449
718
|
onClick={() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' })}
|
|
450
|
-
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 ${
|
|
719
|
+
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 ${
|
|
451
720
|
mode === 'tail' && !isAtBottom && !isLoadingNew ? 'visible' : 'invisible'
|
|
452
721
|
}`}
|
|
453
722
|
>
|
|
454
|
-
↓ Live
|
|
723
|
+
↓ Live
|
|
455
724
|
</button>
|
|
456
725
|
</div>
|
|
457
726
|
</div>
|