diff --git a/web/app/globals.css b/web/app/globals.css index a2dc41e..ca65309 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -24,3 +24,19 @@ body { color: var(--foreground); font-family: Arial, Helvetica, sans-serif; } + +/* Custom animations */ +@keyframes fade-in { + from { + opacity: 0; + transform: translateX(-50%) translateY(4px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +.animate-fade-in { + animation: fade-in 0.2s ease-out forwards; +} diff --git a/web/components/JobDevTools/CopyToolbar.tsx b/web/components/JobDevTools/CopyToolbar.tsx new file mode 100644 index 0000000..bf8fc35 --- /dev/null +++ b/web/components/JobDevTools/CopyToolbar.tsx @@ -0,0 +1,307 @@ +'use client'; + +import { useState, useCallback, useRef, useEffect } from 'react'; +import { Copy, Check, Download, ChevronDown } from 'lucide-react'; +import { StructuredLog } from './index'; +import { + ExportFormat, + copyToClipboard, + formatLogsForExport, + downloadAsFile, + getExportFileInfo, +} from '@/lib/copy-utils'; + +export interface CopyToolbarProps { + logs: StructuredLog[]; + selectedIndices?: Set; + onCopySuccess?: () => void; +} + +const FORMAT_OPTIONS: { value: ExportFormat; label: string }[] = [ + { value: 'text', label: 'Text' }, + { value: 'json', label: 'JSON' }, + { value: 'csv', label: 'CSV' }, +]; + +/** + * Toast notification component for copy success feedback + */ +function Toast({ + message, + isVisible, + onClose, +}: { + message: string; + isVisible: boolean; + onClose: () => void; +}) { + useEffect(() => { + if (isVisible) { + const timer = setTimeout(onClose, 2500); + return () => clearTimeout(timer); + } + }, [isVisible, onClose]); + + if (!isVisible) return null; + + return ( +
+
+ + {message} +
+
+ ); +} + +/** + * Format dropdown component + */ +function FormatDropdown({ + value, + onChange, + disabled, +}: { + value: ExportFormat; + onChange: (format: ExportFormat) => void; + disabled?: boolean; +}) { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + // Close dropdown when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [isOpen]); + + const selectedOption = FORMAT_OPTIONS.find((opt) => opt.value === value); + + return ( +
+ + + {isOpen && ( +
+ {FORMAT_OPTIONS.map((option) => ( + + ))} +
+ )} +
+ ); +} + +/** + * CopyToolbar - Toolbar for copying and exporting logs + * + * Features: + * - Copy All button to copy all logs + * - Copy Selected button to copy only selected logs + * - Format dropdown to choose Text, JSON, or CSV + * - Download button for exporting as file + * - Toast notification on copy success + */ +export default function CopyToolbar({ + logs, + selectedIndices = new Set(), + onCopySuccess, +}: CopyToolbarProps) { + const [format, setFormat] = useState('text'); + const [toastMessage, setToastMessage] = useState(''); + const [showToast, setShowToast] = useState(false); + const [copyingAll, setCopyingAll] = useState(false); + const [copyingSelected, setCopyingSelected] = useState(false); + + const hasSelection = selectedIndices.size > 0; + const hasLogs = logs.length > 0; + + // Get selected logs from indices + const getSelectedLogs = useCallback((): StructuredLog[] => { + return logs.filter((_, index) => selectedIndices.has(index)); + }, [logs, selectedIndices]); + + // Show toast notification + const showToastMessage = useCallback((message: string) => { + setToastMessage(message); + setShowToast(true); + }, []); + + // Handle copy all logs + const handleCopyAll = useCallback(async () => { + if (!hasLogs) return; + + setCopyingAll(true); + try { + const content = formatLogsForExport(logs, format); + const success = await copyToClipboard(content); + + if (success) { + showToastMessage(`Copied ${logs.length} log${logs.length !== 1 ? 's' : ''}`); + onCopySuccess?.(); + } else { + showToastMessage('Failed to copy'); + } + } finally { + setCopyingAll(false); + } + }, [logs, format, hasLogs, showToastMessage, onCopySuccess]); + + // Handle copy selected logs + const handleCopySelected = useCallback(async () => { + if (!hasSelection) return; + + setCopyingSelected(true); + try { + const selectedLogs = getSelectedLogs(); + const content = formatLogsForExport(selectedLogs, format); + const success = await copyToClipboard(content); + + if (success) { + showToastMessage(`Copied ${selectedLogs.length} log${selectedLogs.length !== 1 ? 's' : ''}`); + onCopySuccess?.(); + } else { + showToastMessage('Failed to copy'); + } + } finally { + setCopyingSelected(false); + } + }, [getSelectedLogs, format, hasSelection, showToastMessage, onCopySuccess]); + + // Handle download + const handleDownload = useCallback(() => { + const logsToExport = hasSelection ? getSelectedLogs() : logs; + if (logsToExport.length === 0) return; + + const content = formatLogsForExport(logsToExport, format); + const { extension, mimeType } = getExportFileInfo(format); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const filename = `logs-${timestamp}.${extension}`; + + downloadAsFile(content, filename, mimeType); + showToastMessage(`Downloaded ${logsToExport.length} log${logsToExport.length !== 1 ? 's' : ''}`); + }, [logs, format, hasSelection, getSelectedLogs, showToastMessage]); + + return ( +
+ {/* Toast notification */} + setShowToast(false)} + /> + + {/* Copy All button */} + + + {/* Copy Selected button */} + + + {/* Separator */} +
+ + {/* Format dropdown */} +
+ Format: + +
+ + {/* Separator */} +
+ + {/* Download button */} + + + {/* Selection info */} + {hasSelection && ( + + {selectedIndices.size} of {logs.length} selected + + )} +
+ ); +} diff --git a/web/components/JobDevTools/LogEntry.tsx b/web/components/JobDevTools/LogEntry.tsx new file mode 100644 index 0000000..2e47a7f --- /dev/null +++ b/web/components/JobDevTools/LogEntry.tsx @@ -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 = { + 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 = { + 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 = { + 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 ? ( + + {part} + + ) : ( + {part} + ); + })} + + ); +} + +/** + * 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(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 ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {/* Main row */} +
+ {/* Expand chevron (if expandable) */} +
+ {expandable && ( + + )} +
+ + {/* Timestamp */} + + {timestamp} + + + {/* Level badge */} + + {log.level} + + + {/* Category badge */} + + {log.category} + + + {/* Message */} + + + + + {/* Copy button (visible on hover) */} + +
+ + {/* Expanded content */} + {isExpanded && expandable && ( +
+ {/* Full message (no truncation) */} +
+ +
+ + {/* Metrics */} + {log.metrics && Object.keys(log.metrics).length > 0 && ( +
+
Metrics:
+
+ {Object.entries(log.metrics).map(([key, value]) => ( +
+ {key}: + + {typeof value === 'number' + ? value.toLocaleString() + : typeof value === 'object' + ? JSON.stringify(value) + : String(value)} + +
+ ))} +
+
+ )} + + {/* Network info */} + {log.network && Object.keys(log.network).length > 0 && ( +
+
Network:
+
+ {log.network.status !== undefined && ( +
+ Status: + = 200 && log.network.status < 300 + ? 'text-green-400' + : log.network.status >= 400 + ? 'text-red-400' + : 'text-yellow-400' + }`} + > + {log.network.status} + +
+ )} + {log.network.url && ( +
+ URL: + + {log.network.url} + +
+ )} + {log.network.duration !== undefined && ( +
+ Duration: + + {log.network.duration}ms + +
+ )} + {/* Render any other network properties */} + {Object.entries(log.network) + .filter(([key]) => !['status', 'url', 'duration'].includes(key)) + .map(([key, value]) => ( +
+ {key}: + + {typeof value === 'object' ? JSON.stringify(value) : String(value)} + +
+ ))} +
+
+ )} +
+ )} +
+ ); +} + +// Export types for external use +export type { LogLevel, LogCategory }; diff --git a/web/lib/copy-utils.ts b/web/lib/copy-utils.ts new file mode 100644 index 0000000..de1277f --- /dev/null +++ b/web/lib/copy-utils.ts @@ -0,0 +1,217 @@ +// Copy utilities for log management + +import { StructuredLog } from '@/components/JobDevTools'; + +/** + * Format a single log entry for copying as plain text + * Format: [TIMESTAMP] [LEVEL] [CATEGORY] MESSAGE + * Includes metrics/network data if present + */ +export function formatLogForCopy(log: StructuredLog): string { + const timestamp = formatTimestamp(log.timestamp); + let line = `[${timestamp}] [${log.level}] [${log.category}] ${log.message}`; + + // Include metrics if present + if (log.metrics && Object.keys(log.metrics).length > 0) { + line += `\n metrics: ${JSON.stringify(log.metrics)}`; + } + + // Include network data if present + if (log.network && Object.keys(log.network).length > 0) { + line += `\n network: ${JSON.stringify(log.network)}`; + } + + return line; +} + +/** + * Format multiple logs for copying as plain text + * Joins logs with newlines and includes a header with count + */ +export function formatLogsForCopy(logs: StructuredLog[]): string { + if (logs.length === 0) { + return 'No logs to copy.'; + } + + const header = `=== ${logs.length} Log${logs.length !== 1 ? 's' : ''} ===`; + const separator = '='.repeat(header.length); + const formattedLogs = logs.map(formatLogForCopy).join('\n'); + + return `${header}\n${separator}\n${formattedLogs}\n${separator}`; +} + +/** + * Copy text to clipboard with fallback for older browsers + * Returns true if copy was successful, false otherwise + */ +export async function copyToClipboard(text: string): Promise { + // Try modern Clipboard API first + if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { + try { + await navigator.clipboard.writeText(text); + return true; + } catch (err) { + console.warn('Clipboard API failed, trying fallback:', err); + } + } + + // Fallback to execCommand for older browsers + try { + const textArea = document.createElement('textarea'); + textArea.value = text; + + // Make the textarea invisible + textArea.style.position = 'fixed'; + textArea.style.left = '-9999px'; + textArea.style.top = '-9999px'; + textArea.style.opacity = '0'; + + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + const success = document.execCommand('copy'); + document.body.removeChild(textArea); + + return success; + } catch (err) { + console.error('Fallback copy failed:', err); + return false; + } +} + +/** + * Format logs as pretty-printed JSON for export + */ +export function formatLogsAsJson(logs: StructuredLog[]): string { + const exportData = { + exportedAt: new Date().toISOString(), + count: logs.length, + logs: logs.map((log) => ({ + timestamp: log.timestamp, + timestamp_ms: log.timestamp_ms, + level: log.level, + category: log.category, + message: log.message, + ...(log.metrics && { metrics: log.metrics }), + ...(log.network && { network: log.network }), + })), + }; + + return JSON.stringify(exportData, null, 2); +} + +/** + * Format logs as CSV for export + * Includes headers and properly escapes values + */ +export function formatLogsAsCsv(logs: StructuredLog[]): string { + if (logs.length === 0) { + return 'timestamp,timestamp_ms,level,category,message,metrics,network'; + } + + const headers = ['timestamp', 'timestamp_ms', 'level', 'category', 'message', 'metrics', 'network']; + const headerRow = headers.join(','); + + const rows = logs.map((log) => { + const values = [ + escapeCsvValue(log.timestamp), + log.timestamp_ms.toString(), + log.level, + log.category, + escapeCsvValue(log.message), + escapeCsvValue(log.metrics ? JSON.stringify(log.metrics) : ''), + escapeCsvValue(log.network ? JSON.stringify(log.network) : ''), + ]; + return values.join(','); + }); + + return [headerRow, ...rows].join('\n'); +} + +/** + * Download content as a file + */ +export function downloadAsFile(content: string, filename: string, mimeType: string): void { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.style.display = 'none'; + + document.body.appendChild(link); + link.click(); + + // Clean up + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +/** + * Get file extension and MIME type for a format + */ +export function getExportFileInfo(format: ExportFormat): { extension: string; mimeType: string } { + switch (format) { + case 'json': + return { extension: 'json', mimeType: 'application/json' }; + case 'csv': + return { extension: 'csv', mimeType: 'text/csv' }; + case 'text': + default: + return { extension: 'txt', mimeType: 'text/plain' }; + } +} + +/** + * Format logs based on export format + */ +export function formatLogsForExport(logs: StructuredLog[], format: ExportFormat): string { + switch (format) { + case 'json': + return formatLogsAsJson(logs); + case 'csv': + return formatLogsAsCsv(logs); + case 'text': + default: + return formatLogsForCopy(logs); + } +} + +// Helper functions + +/** + * Format timestamp for display + */ +function formatTimestamp(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; + } +} + +/** + * Escape a value for CSV (handles quotes and commas) + */ +function escapeCsvValue(value: string): string { + if (!value) return ''; + + // If value contains comma, newline, or quote, wrap in quotes and escape internal quotes + if (value.includes(',') || value.includes('\n') || value.includes('"')) { + return `"${value.replace(/"/g, '""')}"`; + } + + return value; +} + +// Types +export type ExportFormat = 'text' | 'json' | 'csv';