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:
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user