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:
@@ -24,3 +24,19 @@ body {
|
|||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
307
web/components/JobDevTools/CopyToolbar.tsx
Normal file
307
web/components/JobDevTools/CopyToolbar.tsx
Normal file
@@ -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<number>;
|
||||||
|
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 (
|
||||||
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50 animate-fade-in">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 bg-green-600 text-white text-sm font-medium rounded-lg shadow-lg whitespace-nowrap">
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format dropdown component
|
||||||
|
*/
|
||||||
|
function FormatDropdown({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
value: ExportFormat;
|
||||||
|
onChange: (format: ExportFormat) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<div ref={dropdownRef} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md border transition-colors ${
|
||||||
|
disabled
|
||||||
|
? 'bg-gray-800 text-gray-500 border-gray-700 cursor-not-allowed'
|
||||||
|
: 'bg-gray-700 text-gray-300 border-gray-600 hover:bg-gray-600 hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{selectedOption?.label || 'Text'}</span>
|
||||||
|
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute top-full left-0 mt-1 z-20 bg-gray-800 border border-gray-600 rounded-lg shadow-lg py-1 min-w-[80px]">
|
||||||
|
{FORMAT_OPTIONS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(option.value);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
className={`w-full px-3 py-1.5 text-xs text-left transition-colors ${
|
||||||
|
value === option.value
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'text-gray-300 hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<ExportFormat>('text');
|
||||||
|
const [toastMessage, setToastMessage] = useState<string>('');
|
||||||
|
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 (
|
||||||
|
<div className="relative flex items-center gap-2 px-3 py-2 bg-gray-800 border-b border-gray-700">
|
||||||
|
{/* Toast notification */}
|
||||||
|
<Toast
|
||||||
|
message={toastMessage}
|
||||||
|
isVisible={showToast}
|
||||||
|
onClose={() => setShowToast(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Copy All button */}
|
||||||
|
<button
|
||||||
|
onClick={handleCopyAll}
|
||||||
|
disabled={!hasLogs || copyingAll}
|
||||||
|
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||||
|
hasLogs && !copyingAll
|
||||||
|
? 'bg-blue-600 text-white hover:bg-blue-500'
|
||||||
|
: 'bg-gray-700 text-gray-500 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
title="Copy all logs to clipboard"
|
||||||
|
>
|
||||||
|
{copyingAll ? (
|
||||||
|
<span className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
<span>Copy All</span>
|
||||||
|
{hasLogs && (
|
||||||
|
<span className="px-1.5 py-0.5 bg-blue-700 rounded text-[10px]">{logs.length}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Copy Selected button */}
|
||||||
|
<button
|
||||||
|
onClick={handleCopySelected}
|
||||||
|
disabled={!hasSelection || copyingSelected}
|
||||||
|
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||||
|
hasSelection && !copyingSelected
|
||||||
|
? 'bg-green-600 text-white hover:bg-green-500'
|
||||||
|
: 'bg-gray-700 text-gray-500 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
title={hasSelection ? 'Copy selected logs to clipboard' : 'Select logs to copy'}
|
||||||
|
>
|
||||||
|
{copyingSelected ? (
|
||||||
|
<span className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
<span>Copy Selected</span>
|
||||||
|
{hasSelection && (
|
||||||
|
<span className="px-1.5 py-0.5 bg-green-700 rounded text-[10px]">
|
||||||
|
{selectedIndices.size}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Separator */}
|
||||||
|
<div className="w-px h-5 bg-gray-600" />
|
||||||
|
|
||||||
|
{/* Format dropdown */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-xs text-gray-400">Format:</span>
|
||||||
|
<FormatDropdown value={format} onChange={setFormat} disabled={!hasLogs} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Separator */}
|
||||||
|
<div className="w-px h-5 bg-gray-600" />
|
||||||
|
|
||||||
|
{/* Download button */}
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={!hasLogs && !hasSelection}
|
||||||
|
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||||
|
hasLogs || hasSelection
|
||||||
|
? 'bg-gray-700 text-gray-300 border border-gray-600 hover:bg-gray-600 hover:text-gray-200'
|
||||||
|
: 'bg-gray-800 text-gray-500 border border-gray-700 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
title={hasSelection ? 'Download selected logs as file' : 'Download all logs as file'}
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
<span>Download</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Selection info */}
|
||||||
|
{hasSelection && (
|
||||||
|
<span className="ml-auto text-xs text-gray-400">
|
||||||
|
{selectedIndices.size} of {logs.length} selected
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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 };
|
||||||
217
web/lib/copy-utils.ts
Normal file
217
web/lib/copy-utils.ts
Normal file
@@ -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<boolean> {
|
||||||
|
// 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';
|
||||||
Reference in New Issue
Block a user