feat: Add "Copy Crash Report" button for failed/partial jobs

- Generate structured markdown crash report optimized for Claude
- Includes: job metadata, timeline, progress, error, logs (last 50)
- Adds context and suggested investigation steps
- Orange clipboard button appears for failed/partial jobs
- Shows green checkmark briefly after successful copy
- Fetches logs async when generating report

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-01-24 17:09:48 +00:00
parent acd3b22e88
commit 65eb979c12

View File

@@ -211,6 +211,110 @@ export default function JobsView({ jobs, onSelectJob, isLoadingJob, onRefresh }:
} }
}; };
// Generate crash report for a job
const [copyingCrashReport, setCopyingCrashReport] = useState<string | null>(null);
const generateCrashReport = async (job: JobStatus): Promise<string> => {
// Fetch logs for the job
let logs: LogEntry[] = [];
let logCount = 0;
try {
const response = await fetch(`/api/jobs/${job.job_id}/logs`);
if (response.ok) {
const data = await response.json();
logs = data.logs || [];
logCount = data.log_count || 0;
}
} catch (err) {
console.error('Failed to fetch logs for crash report:', err);
}
const businessName = extractBusinessName(job);
const now = new Date().toISOString();
// Format logs (last 50 entries)
const recentLogs = [...logs]
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, 50)
.reverse();
const logsFormatted = recentLogs.length > 0
? recentLogs.map(log =>
`[${new Date(log.timestamp).toISOString()}] [${log.level.toUpperCase()}] [${log.source}] ${log.message}`
).join('\n')
: 'No logs available';
// Calculate duration
const duration = job.scrape_time
? formatDuration(job.scrape_time)
: job.started_at
? formatDuration((Date.now() - new Date(job.started_at).getTime()) / 1000)
: 'Unknown';
// Build the crash report
const report = `## Crash Report: ${job.job_id}
**Generated**: ${now}
**Status**: ${job.status.toUpperCase()}
**Job Type**: google-reviews
### Business Info
- **Name**: ${businessName}
- **Address**: ${job.business_address || 'N/A'}
- **Category**: ${job.business_category || 'N/A'}
- **URL**: ${job.url}
### Job Timeline
- **Created**: ${job.created_at}
- **Started**: ${job.started_at || 'N/A'}
- **Completed**: ${job.completed_at || 'N/A'}
- **Last Update**: ${job.updated_at || 'N/A'}
- **Duration**: ${duration}
### Progress at Failure
- **Reviews Collected**: ${job.reviews_count ?? 0}${job.total_reviews ? ` / ${job.total_reviews}` : ''}
- **Expected Total**: ${job.total_reviews_snapshot ?? 'Unknown'}
- **Rating Snapshot**: ${job.rating_snapshot ?? 'N/A'}
### Error
\`\`\`
${job.error_message || 'No error message captured'}
\`\`\`
### Logs (${logCount} total, showing last ${recentLogs.length})
\`\`\`
${logsFormatted}
\`\`\`
### Context for Debugging
- This is a Google Reviews scraper job
- The scraper uses Playwright to navigate Google Maps
- Reviews are extracted by scrolling through the reviews panel
- Common failure points: rate limiting, DOM structure changes, network timeouts
### Suggested Investigation
1. Check if error is related to rate limiting (look for 429 or "too many requests")
2. Check if DOM selectors have changed (look for "element not found" errors)
3. Check network/timeout issues (look for "timeout" or "navigation" errors)
4. Review the last few log entries before the error for context
`;
return report;
};
const copyCrashReport = async (job: JobStatus) => {
setCopyingCrashReport(job.job_id);
try {
const report = await generateCrashReport(job);
await navigator.clipboard.writeText(report);
// Brief visual feedback
setTimeout(() => setCopyingCrashReport(null), 1500);
} catch (err) {
console.error('Failed to copy crash report:', err);
setCopyingCrashReport(null);
}
};
// Live monitoring functions // Live monitoring functions
const startMonitoring = useCallback((job: JobStatus) => { const startMonitoring = useCallback((job: JobStatus) => {
setMonitoredJob(job); setMonitoredJob(job);
@@ -887,6 +991,30 @@ export default function JobsView({ jobs, onSelectJob, isLoadingJob, onRefresh }:
</button> </button>
)} )}
{/* Copy Crash Report - for failed or partial jobs */}
{(job.status === 'failed' || job.status === 'partial') && (
<button
onClick={() => copyCrashReport(job)}
disabled={copyingCrashReport === job.job_id}
className={`p-1.5 rounded-lg transition-colors ${
copyingCrashReport === job.job_id
? 'bg-green-100 text-green-700'
: 'bg-orange-100 text-orange-700 hover:bg-orange-200'
}`}
title={copyingCrashReport === job.job_id ? 'Copied!' : 'Copy crash report for Claude'}
>
{copyingCrashReport === job.job_id ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
)}
</button>
)}
{/* Delete Job - allow for non-running or stuck jobs */} {/* Delete Job - allow for non-running or stuck jobs */}
{(() => { {(() => {
const isStuck = job.status === 'running' && const isStuck = job.status === 'running' &&
@@ -914,7 +1042,7 @@ export default function JobsView({ jobs, onSelectJob, isLoadingJob, onRefresh }:
}, },
}, },
], ],
[isLoadingJob, loadingLogs, expandedErrors, jobsWithUpdates, startMonitoring, isDeleting] [isLoadingJob, loadingLogs, expandedErrors, jobsWithUpdates, startMonitoring, isDeleting, copyingCrashReport, copyCrashReport]
); );
const table = useReactTable({ const table = useReactTable({