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:
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