import { useState, useRef, useCallback, useEffect } from 'react'; import { Bug, X, Loader2, CheckCircle, AlertCircle, AlertTriangle, Trash2, Upload, Circle, CheckCircle2, Stethoscope } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { api, bugReportApi, type PrinterDiagnosticResult } from '../api/client'; import { DiagnosticChecklist } from './ConnectionDiagnostic'; import { SystemHealthPanel } from './SystemHealthPanel'; import { Collapsible } from './Collapsible'; type ViewState = 'form' | 'logging' | 'stopping' | 'submitting' | 'success' | 'error'; /** One scanned printer paired with its name — the diagnostic result alone * carries no name, and the bug-report panel lists affected printers by name. */ type DiagnosticEntry = { name: string; result: PrinterDiagnosticResult }; const MAX_DIMENSION = 1920; const JPEG_QUALITY = 0.7; const MAX_LOG_SECONDS = 300; // 5 minutes function compressImage(file: File): Promise { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { let { width, height } = img; if (width > MAX_DIMENSION || height > MAX_DIMENSION) { const scale = MAX_DIMENSION / Math.max(width, height); width = Math.round(width * scale); height = Math.round(height * scale); } const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); if (!ctx) { reject(new Error('No canvas context')); return; } ctx.drawImage(img, 0, 0, width, height); const dataUrl = canvas.toDataURL('image/jpeg', JPEG_QUALITY); resolve(dataUrl.replace(/^data:[^;]+;base64,/, '')); }; img.onerror = reject; img.src = URL.createObjectURL(file); }); } function formatElapsed(seconds: number): string { const m = Math.floor(seconds / 60); const s = seconds % 60; return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; } export function BugReportBubble() { const { t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); const [viewState, setViewState] = useState('form'); const [description, setDescription] = useState(''); const [email, setEmail] = useState(''); const [screenshot, setScreenshot] = useState(null); const [isDragging, setIsDragging] = useState(false); const [issueUrl, setIssueUrl] = useState(null); const [issueNumber, setIssueNumber] = useState(null); const [errorMessage, setErrorMessage] = useState(''); const [elapsedSeconds, setElapsedSeconds] = useState(0); const [wasDebug, setWasDebug] = useState(false); const modalRef = useRef(null); const fileInputRef = useRef(null); const handleStopLoggingRef = useRef<() => void>(() => {}); // Before the user files a report, diagnose configured printers. Most bug // reports are setup issues — surfacing a connection problem inline lets the // user self-fix instead of waiting on a triage round-trip. The result is // always shown (healthy or not) so the user can see the check ran. const diagnosticScan = useQuery({ queryKey: ['bugReportDiagnostic'], enabled: isOpen && viewState === 'form', staleTime: 30_000, queryFn: async (): Promise => { const printers = await api.getPrinters(); const entries = await Promise.all( printers.map(async (p) => { const result = await api.diagnosePrinter(p.id).catch(() => null); return result ? { name: p.name, result } : null; }), ); return entries.filter((e): e is DiagnosticEntry => e !== null); }, }); const diagnosticEntries = diagnosticScan.data ?? []; const diagnosticProblems = diagnosticEntries.filter((e) => e.result.overall === 'problems'); // Scan recent logs against the known-issue catalog. Like the diagnostic // above, this surfaces user-fixable ("layer 8") problems before a report is // filed. Only shown when something matched — a clean scan stays silent so // the form is uncluttered. const logHealthScan = useQuery({ queryKey: ['bugReportLogHealth'], enabled: isOpen && viewState === 'form', staleTime: 30_000, queryFn: api.getSystemHealth, }); const logFindings = logHealthScan.data?.findings ?? []; // Elapsed timer for logging phase — auto-stop at 5 minutes useEffect(() => { if (viewState !== 'logging') return; if (elapsedSeconds >= MAX_LOG_SECONDS) { handleStopLoggingRef.current(); return; } const timer = setTimeout(() => setElapsedSeconds((s) => s + 1), 1000); return () => clearTimeout(timer); }, [viewState, elapsedSeconds]); const handleOpen = () => { setIsOpen(true); setViewState('form'); setDescription(''); setEmail(''); setScreenshot(null); setIssueUrl(null); setIssueNumber(null); setErrorMessage(''); setElapsedSeconds(0); setWasDebug(false); }; const handleClose = () => { setIsOpen(false); }; const handleFile = useCallback(async (file: File) => { if (!file.type.startsWith('image/')) return; try { const b64 = await compressImage(file); setScreenshot(b64); } catch { // Ignore read errors } }, []); const handlePaste = useCallback((e: React.ClipboardEvent) => { const items = e.clipboardData?.items; if (!items) return; for (const item of items) { if (item.type.startsWith('image/')) { const file = item.getAsFile(); if (file) handleFile(file); break; } } }, [handleFile]); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); }, []); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); }, []); const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); const file = e.dataTransfer.files?.[0]; if (file) handleFile(file); }, [handleFile]); const handleStartLogging = async () => { if (!description.trim()) return; try { const result = await bugReportApi.startLogging(); setWasDebug(result.was_debug); setElapsedSeconds(0); setViewState('logging'); } catch (err) { setErrorMessage(err instanceof Error ? err.message : t('bugReport.unexpectedError')); setViewState('error'); } }; const handleStopLogging = async () => { setViewState('stopping'); try { const stopResult = await bugReportApi.stopLogging(wasDebug); await handleSubmitReport(stopResult.logs); } catch (err) { setErrorMessage(err instanceof Error ? err.message : t('bugReport.unexpectedError')); setViewState('error'); } }; handleStopLoggingRef.current = handleStopLogging; const handleSubmitReport = async (debugLogs: string) => { setViewState('submitting'); try { const result = await bugReportApi.submit({ description: description.trim(), email: email.trim() || undefined, screenshot_base64: screenshot || undefined, include_support_info: true, debug_logs: debugLogs || undefined, }); if (result.success) { setIssueUrl(result.issue_url || null); setIssueNumber(result.issue_number || null); setViewState('success'); } else { setErrorMessage(result.message); setViewState('error'); } } catch (err) { setErrorMessage(err instanceof Error ? err.message : t('bugReport.unexpectedError')); setViewState('error'); } }; return ( <> {/* Floating bubble */} {/* Slide-in panel anchored to bottom-right */} {isOpen && (
{/* Header */}

{t('bugReport.title')}

{viewState === 'form' && ( <> {/* Connection diagnostic — scanned on form-open. A healthy fleet shows a single confirmation line. When printers have problems, each is a collapsed row (auto-expanded when only one) so the form stays reachable regardless of how many printers are configured. */} {diagnosticScan.isLoading && (
{t('bugReport.diagnosticChecking')}
)} {!diagnosticScan.isLoading && diagnosticProblems.length > 0 && (

{t('bugReport.diagnosticSummary', { problems: diagnosticProblems.length, total: diagnosticEntries.length, })}

{t('bugReport.diagnosticIntro')}

{diagnosticProblems.map((entry) => ( {entry.name}
} > ))}
)} {!diagnosticScan.isLoading && diagnosticEntries.length > 0 && diagnosticProblems.length === 0 && (

{t('bugReport.diagnosticHealthy')}

)} {/* Log-health scan — known issues found in recent logs. Shown only when something matched. */} {!logHealthScan.isLoading && logFindings.length > 0 && logHealthScan.data && (

{t('bugReport.logHealthSummary')}

{t('bugReport.logHealthIntro')}

)} {/* Description */}