Files
whyrating-engine-legacy/web/lib/copy-utils.ts
Alejandro Gutiérrez c6443166b2 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>
2026-01-24 12:51:48 +00:00

218 lines
5.7 KiB
TypeScript

// 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';