Wave 6: CopyToolbar utilities and LogEntry row component
- Task #7: Create CopyToolbar and copy utilities (copy-utils.ts with text/JSON/CSV formatting, clipboard API with fallback) (CopyToolbar with copy all/selected, format dropdown, download export) - Task #8: Create LogEntry row component (click-to-copy with visual feedback, expandable metrics view) (level/category badges, search highlighting, shift+click selection) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
413
web/components/JobDevTools/LogEntry.tsx
Normal file
413
web/components/JobDevTools/LogEntry.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { Copy, ChevronRight, ChevronDown, Check } from 'lucide-react';
|
||||
import { StructuredLog } from './index';
|
||||
import { copyToClipboard, formatLogForCopy } from '@/lib/copy-utils';
|
||||
|
||||
// Types
|
||||
type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' | 'FATAL';
|
||||
type LogCategory = 'scraper' | 'browser' | 'network' | 'system';
|
||||
|
||||
export interface LogEntryProps {
|
||||
log: StructuredLog;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
searchQuery?: string;
|
||||
timestampFormat: 'relative' | 'absolute';
|
||||
onSelect: (index: number, shiftKey: boolean) => void;
|
||||
onCopy: (log: StructuredLog) => void;
|
||||
}
|
||||
|
||||
// Styling constants - matching the dark theme
|
||||
const LEVEL_BADGE_COLORS: Record<LogLevel, string> = {
|
||||
DEBUG: 'bg-gray-600 text-gray-200',
|
||||
INFO: 'bg-blue-600 text-blue-100',
|
||||
WARN: 'bg-yellow-600 text-yellow-100',
|
||||
ERROR: 'bg-red-600 text-red-100',
|
||||
FATAL: 'bg-purple-600 text-purple-100',
|
||||
};
|
||||
|
||||
const CATEGORY_BADGE_COLORS: Record<LogCategory, string> = {
|
||||
scraper: 'bg-green-700 text-green-200',
|
||||
browser: 'bg-orange-700 text-orange-200',
|
||||
network: 'bg-cyan-700 text-cyan-200',
|
||||
system: 'bg-gray-600 text-gray-200',
|
||||
};
|
||||
|
||||
const LEVEL_ROW_COLORS: Record<LogLevel, string> = {
|
||||
DEBUG: 'text-gray-300',
|
||||
INFO: 'text-blue-300',
|
||||
WARN: 'text-yellow-300',
|
||||
ERROR: 'text-red-300',
|
||||
FATAL: 'text-purple-300',
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a timestamp as absolute time (HH:MM:SS.mmm)
|
||||
*/
|
||||
function formatAbsoluteTime(timestamp: string): string {
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
fractionalSecondDigits: 3,
|
||||
});
|
||||
} catch {
|
||||
return timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a timestamp as relative time (e.g., "2s ago", "1m ago")
|
||||
*/
|
||||
function formatRelativeTime(timestampMs: number): string {
|
||||
const nowMs = Date.now();
|
||||
const diffMs = nowMs - timestampMs;
|
||||
const diffSeconds = Math.floor(diffMs / 1000);
|
||||
|
||||
if (diffSeconds < 0) {
|
||||
return 'just now';
|
||||
}
|
||||
|
||||
if (diffSeconds < 60) {
|
||||
return `${diffSeconds}s ago`;
|
||||
}
|
||||
|
||||
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||
if (diffMinutes < 60) {
|
||||
return `${diffMinutes}m ago`;
|
||||
}
|
||||
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
if (diffHours < 24) {
|
||||
return `${diffHours}h ago`;
|
||||
}
|
||||
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlights matching text in a string
|
||||
*/
|
||||
function HighlightedText({ text, query }: { text: string; query: string }) {
|
||||
if (!query || !query.trim()) {
|
||||
return <>{text}</>;
|
||||
}
|
||||
|
||||
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
||||
const parts = text.split(regex);
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) => {
|
||||
const isMatch = part.toLowerCase() === query.toLowerCase();
|
||||
return isMatch ? (
|
||||
<mark
|
||||
key={index}
|
||||
className="bg-yellow-500/40 text-yellow-100 rounded px-0.5"
|
||||
>
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={index}>{part}</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a log has expandable content (metrics or network data)
|
||||
*/
|
||||
function hasExpandableContent(log: StructuredLog): boolean {
|
||||
const hasMetrics = !!(log.metrics && Object.keys(log.metrics).length > 0);
|
||||
const hasNetwork = !!(log.network && Object.keys(log.network).length > 0);
|
||||
return hasMetrics || hasNetwork;
|
||||
}
|
||||
|
||||
/**
|
||||
* LogEntry - Individual log row component with click-to-copy functionality
|
||||
*
|
||||
* Features:
|
||||
* - Click to select (with shift key support for range selection)
|
||||
* - Double-click or copy icon to copy single log entry
|
||||
* - Visual feedback on copy (brief green flash animation)
|
||||
* - Level badge with colors (DEBUG=gray, INFO=blue, WARN=yellow, ERROR=red, FATAL=purple)
|
||||
* - Category badge with colors (scraper=green, browser=orange, network=cyan, system=gray)
|
||||
* - Expandable metrics view for logs with metrics/network data
|
||||
* - Search query highlighting in message text
|
||||
*/
|
||||
export default function LogEntry({
|
||||
log,
|
||||
index,
|
||||
isSelected,
|
||||
searchQuery = '',
|
||||
timestampFormat,
|
||||
onSelect,
|
||||
onCopy,
|
||||
}: LogEntryProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [copyFeedback, setCopyFeedback] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const feedbackTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (feedbackTimeoutRef.current) {
|
||||
clearTimeout(feedbackTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const timestamp =
|
||||
timestampFormat === 'absolute'
|
||||
? formatAbsoluteTime(log.timestamp)
|
||||
: formatRelativeTime(log.timestamp_ms);
|
||||
|
||||
const levelBadgeClass = LEVEL_BADGE_COLORS[log.level] || LEVEL_BADGE_COLORS.INFO;
|
||||
const categoryBadgeClass = CATEGORY_BADGE_COLORS[log.category] || CATEGORY_BADGE_COLORS.system;
|
||||
const messageColor = LEVEL_ROW_COLORS[log.level] || LEVEL_ROW_COLORS.INFO;
|
||||
|
||||
const isEven = index % 2 === 0;
|
||||
const expandable = hasExpandableContent(log);
|
||||
|
||||
// Handle single click for selection
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
onSelect(index, e.shiftKey);
|
||||
},
|
||||
[index, onSelect]
|
||||
);
|
||||
|
||||
// Perform copy operation with visual feedback
|
||||
const performCopy = useCallback(async () => {
|
||||
const formattedLog = formatLogForCopy(log);
|
||||
const success = await copyToClipboard(formattedLog);
|
||||
|
||||
if (success) {
|
||||
// Show visual feedback
|
||||
setCopyFeedback(true);
|
||||
onCopy(log);
|
||||
|
||||
// Clear feedback after animation
|
||||
if (feedbackTimeoutRef.current) {
|
||||
clearTimeout(feedbackTimeoutRef.current);
|
||||
}
|
||||
feedbackTimeoutRef.current = setTimeout(() => {
|
||||
setCopyFeedback(false);
|
||||
}, 1000);
|
||||
}
|
||||
}, [log, onCopy]);
|
||||
|
||||
// Handle double-click for copy
|
||||
const handleDoubleClick = useCallback(async () => {
|
||||
await performCopy();
|
||||
}, [performCopy]);
|
||||
|
||||
// Handle copy icon click
|
||||
const handleCopyClick = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // Prevent triggering row selection
|
||||
await performCopy();
|
||||
},
|
||||
[performCopy]
|
||||
);
|
||||
|
||||
// Handle expand toggle
|
||||
const handleExpandClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // Prevent triggering row selection
|
||||
setIsExpanded((prev) => !prev);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Build row background class
|
||||
const getRowBgClass = () => {
|
||||
if (copyFeedback) {
|
||||
return 'bg-green-900/50 border-l-2 border-green-500';
|
||||
}
|
||||
if (isSelected) {
|
||||
return 'bg-blue-900/50 border-l-2 border-blue-500';
|
||||
}
|
||||
if (isHovered) {
|
||||
return 'bg-gray-700/50';
|
||||
}
|
||||
return isEven ? 'bg-gray-900/30' : 'bg-gray-800/30';
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`transition-all duration-150 ${copyFeedback ? 'animate-pulse' : ''}`}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Main row */}
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-2 cursor-pointer transition-colors ${getRowBgClass()}`}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{/* Expand chevron (if expandable) */}
|
||||
<div className="w-4 flex-shrink-0">
|
||||
{expandable && (
|
||||
<button
|
||||
onClick={handleExpandClick}
|
||||
className="p-0.5 text-gray-500 hover:text-gray-300 transition-colors"
|
||||
title={isExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<span className="text-gray-500 text-xs font-mono whitespace-nowrap w-[80px] flex-shrink-0">
|
||||
{timestamp}
|
||||
</span>
|
||||
|
||||
{/* Level badge */}
|
||||
<span
|
||||
className={`px-1.5 py-0.5 text-xs font-semibold rounded ${levelBadgeClass} whitespace-nowrap flex-shrink-0 w-[50px] text-center`}
|
||||
>
|
||||
{log.level}
|
||||
</span>
|
||||
|
||||
{/* Category badge */}
|
||||
<span
|
||||
className={`px-1.5 py-0.5 text-xs font-medium rounded ${categoryBadgeClass} whitespace-nowrap flex-shrink-0 w-[60px] text-center`}
|
||||
>
|
||||
{log.category}
|
||||
</span>
|
||||
|
||||
{/* Message */}
|
||||
<span
|
||||
className={`flex-1 font-mono text-sm ${messageColor} ${isExpanded ? '' : 'truncate'}`}
|
||||
title={isExpanded ? undefined : log.message}
|
||||
>
|
||||
<HighlightedText text={log.message} query={searchQuery} />
|
||||
</span>
|
||||
|
||||
{/* Copy button (visible on hover) */}
|
||||
<button
|
||||
onClick={handleCopyClick}
|
||||
className={`p-1.5 rounded transition-all flex-shrink-0 ${
|
||||
isHovered || copyFeedback
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
} ${
|
||||
copyFeedback
|
||||
? 'text-green-400 bg-green-900/50'
|
||||
: 'text-gray-500 hover:text-gray-300 hover:bg-gray-700'
|
||||
}`}
|
||||
title="Copy log entry"
|
||||
>
|
||||
{copyFeedback ? (
|
||||
<Check className="w-4 h-4" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
{isExpanded && expandable && (
|
||||
<div className="px-3 py-2 ml-4 bg-gray-800/50 border-l-2 border-gray-600">
|
||||
{/* Full message (no truncation) */}
|
||||
<div className={`font-mono text-sm ${messageColor} mb-2 break-words`}>
|
||||
<HighlightedText text={log.message} query={searchQuery} />
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
{log.metrics && Object.keys(log.metrics).length > 0 && (
|
||||
<div className="mb-2">
|
||||
<div className="text-xs text-gray-400 font-semibold mb-1">Metrics:</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
||||
{Object.entries(log.metrics).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-1 text-xs bg-gray-700/50 rounded px-2 py-1"
|
||||
>
|
||||
<span className="text-gray-400">{key}:</span>
|
||||
<span className="text-gray-200 font-mono">
|
||||
{typeof value === 'number'
|
||||
? value.toLocaleString()
|
||||
: typeof value === 'object'
|
||||
? JSON.stringify(value)
|
||||
: String(value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Network info */}
|
||||
{log.network && Object.keys(log.network).length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-gray-400 font-semibold mb-1">Network:</div>
|
||||
<div className="flex flex-wrap gap-3 text-xs">
|
||||
{log.network.status !== undefined && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-400">Status:</span>
|
||||
<span
|
||||
className={`font-mono ${
|
||||
log.network.status >= 200 && log.network.status < 300
|
||||
? 'text-green-400'
|
||||
: log.network.status >= 400
|
||||
? 'text-red-400'
|
||||
: 'text-yellow-400'
|
||||
}`}
|
||||
>
|
||||
{log.network.status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{log.network.url && (
|
||||
<div className="flex items-center gap-1 max-w-full">
|
||||
<span className="text-gray-400">URL:</span>
|
||||
<span className="text-cyan-400 font-mono truncate max-w-[300px]" title={log.network.url}>
|
||||
{log.network.url}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{log.network.duration !== undefined && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-400">Duration:</span>
|
||||
<span className="text-gray-200 font-mono">
|
||||
{log.network.duration}ms
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Render any other network properties */}
|
||||
{Object.entries(log.network)
|
||||
.filter(([key]) => !['status', 'url', 'duration'].includes(key))
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-1">
|
||||
<span className="text-gray-400">{key}:</span>
|
||||
<span className="text-gray-200 font-mono">
|
||||
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Export types for external use
|
||||
export type { LogLevel, LogCategory };
|
||||
Reference in New Issue
Block a user