- 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>
218 lines
5.7 KiB
TypeScript
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';
|