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, supportApi, type PrinterDiagnosticResult } from '../api/client'; import { DiagnosticChecklist } from './ConnectionDiagnostic'; import { SystemHealthPanel } from './SystemHealthPanel'; import { Collapsible } from './Collapsible'; import { useIsMobile } from '../hooks/useIsMobile'; 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 /** * A logging run outlives the panel that started it (#2847). * * Step 2 asks the user to reproduce the problem, and the panel sits over the * part of the app they have to reach to do that. Closing it has to be allowed, * so the run is written down rather than held only in component state: the * panel reopens on the step it left, and a reload lands there too instead of * leaving the server at DEBUG with nothing in the UI still tracking it. * * The screenshot is deliberately not persisted. A 1920px JPEG runs to hundreds * of kilobytes against an origin-wide budget this app shares with everything * else it stores, and it survives a close either way — only a reload loses it, * and it is the one optional field on the form. */ const SESSION_KEY = 'bambuddy-bug-report-session'; interface LoggingSession { description: string; email: string; /** Debug logging was already on before this run, so stopping must leave it on. */ wasDebug: boolean; /** Wall clock. Elapsed is derived from it rather than counted in ticks, which * a background tab throttles — the 5-minute cap has to mean five minutes. */ startedAt: number; } function readSession(): LoggingSession | null { try { const raw = window.localStorage.getItem(SESSION_KEY); if (!raw) return null; const parsed = JSON.parse(raw) as Partial; if (typeof parsed?.startedAt !== 'number') return null; return { description: typeof parsed.description === 'string' ? parsed.description : '', email: typeof parsed.email === 'string' ? parsed.email : '', wasDebug: parsed.wasDebug === true, startedAt: parsed.startedAt, }; } catch { // Unparseable or unreadable. Treat it as no session rather than trapping // the user in a panel that cannot restore. return null; } } function writeSession(session: LoggingSession): void { try { window.localStorage.setItem(SESSION_KEY, JSON.stringify(session)); } catch { // Quota, or storage refused outright in a locked-down browser. The run // still works and still survives a close; it just will not survive a // reload, which is no worse than before it was written down at all. } } function clearSession(): void { try { window.localStorage.removeItem(SESSION_KEY); } catch { // See writeSession. } } 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')}`; } interface BugReportBubbleProps { /** * Render the floating disc in the bottom-right corner. False when the * trigger lives somewhere else — the compact header does this (#2750), so * the panel still mounts here while the button that opens it sits in the * header. The panel deliberately stays at the Layout root rather than * moving into the header with its button: the header is a ``fixed z-40`` * element and therefore its own stacking context, so a ``z-50`` panel * nested inside it would be capped at the header's level and end up * underneath every ordinary z-50 modal in the app. */ showTrigger?: boolean; /** Controlled open state. Falls back to internal state when omitted. */ open?: boolean; onOpenChange?: (open: boolean) => void; /** * Fired when a logging run starts or ends. The floating disc shows a live run * itself, but the compact layout replaces the disc with a header button and * has no room for a timer, so Layout uses this to mark that button and to * offer a way back into the run from the debug-logging banner (#2847). */ onLoggingChange?: (active: boolean) => void; } export function BugReportBubble({ showTrigger = true, open, onOpenChange, onLoggingChange }: BugReportBubbleProps = {}) { const { t } = useTranslation(); const isMobile = useIsMobile(); const [internalOpen, setInternalOpen] = useState(false); const isControlled = open !== undefined; const isOpen = isControlled ? open : internalOpen; const setIsOpen = useCallback( (next: boolean) => { if (!isControlled) setInternalOpen(next); onOpenChange?.(next); }, [isControlled, onOpenChange], ); 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 [startedAt, setStartedAt] = useState(null); const [wasDebug, setWasDebug] = useState(false); const modalRef = useRef(null); const fileInputRef = useRef(null); const handleStopLoggingRef = useRef<() => void>(() => {}); // Read inside effects that must not re-run when the view changes. const viewStateRef = useRef(viewState); viewStateRef.current = viewState; const isLogging = viewState === 'logging'; useEffect(() => { onLoggingChange?.(isLogging); }, [isLogging, onLoggingChange]); // 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. Measured against // the run's start time rather than counted in ticks: the run continues while // the panel is closed and while the tab is in the background, where timers // are throttled hard enough that a tick count is not a clock. useEffect(() => { if (viewState !== 'logging' || startedAt === null) return; const tick = () => { const elapsed = Math.floor((Date.now() - startedAt) / 1000); setElapsedSeconds(elapsed); if (elapsed >= MAX_LOG_SECONDS) handleStopLoggingRef.current(); }; tick(); const timer = setInterval(tick, 1000); return () => clearInterval(timer); }, [viewState, startedAt]); // Reset on open rather than in the click handler: the panel now has two // possible triggers (the floating disc here, and the compact header's button // which only flips the controlled flag), and a stale half-filled form // reappearing for one of them would be a nasty little inconsistency. // // A run in progress is the exception (#2847). Step 2 asks the user to // reproduce the problem, which usually means reaching a part of the app the // panel is sitting on top of, so closing it has to be allowed — and the only // thing that stops debug logging is the Stop & Submit button on the step this // reset used to throw away. useEffect(() => { if (!isOpen) return; if (viewStateRef.current === 'logging' || viewStateRef.current === 'stopping' || viewStateRef.current === 'submitting') return; setViewState('form'); setDescription(''); setEmail(''); setScreenshot(null); setIssueUrl(null); setIssueNumber(null); setErrorMessage(''); setElapsedSeconds(0); setStartedAt(null); setWasDebug(false); }, [isOpen]); // Pick a run back up after a reload. The panel's own state is gone by then, // but the server still has the log level raised, so without this the app is // left logging at DEBUG with nothing in the report flow still pointing at it. useEffect(() => { const session = readSession(); if (!session) return; let cancelled = false; (async () => { let stillLogging: boolean; try { stillLogging = (await supportApi.getDebugLoggingState()).enabled; } catch { // Can't tell. Leave the session written down for the next load rather // than dropping a run that may well still be going. return; } if (cancelled || viewStateRef.current !== 'form') return; if (!stillLogging) { // Switched off from the System page, or the run was finished in another // tab. Either way there is nothing left to resume. clearSession(); return; } const elapsed = Math.floor((Date.now() - session.startedAt) / 1000); if (elapsed >= MAX_LOG_SECONDS) { // Past the cap with nobody watching — the browser was closed, or the // tab sat elsewhere for an hour. Put the log level back, but do not // submit: a description written that long ago is not a report anyone is // still expecting to be filed, and no one is here to see it happen. try { await bugReportApi.stopLogging(session.wasDebug); } catch { // The banner in Layout still shows the raised level, and the System // page can lower it. } clearSession(); return; } setDescription(session.description); setEmail(session.email); setWasDebug(session.wasDebug); setStartedAt(session.startedAt); setElapsedSeconds(elapsed); setViewState('logging'); })(); return () => { cancelled = true; }; }, []); const handleOpen = () => setIsOpen(true); 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(); const runStartedAt = Date.now(); setWasDebug(result.was_debug); setStartedAt(runStartedAt); setElapsedSeconds(0); setViewState('logging'); writeSession({ description: description.trim(), email: email.trim(), wasDebug: result.was_debug, startedAt: runStartedAt, }); } catch (err) { setErrorMessage(err instanceof Error ? err.message : t('bugReport.unexpectedError')); setViewState('error'); } }; const handleStopLogging = async () => { // The cap can fire while the panel is closed, and stopping submits. Show // the panel so that happens in front of the user instead of behind them. setIsOpen(true); // The run is over from here whichever way it goes, so there is nothing left // to resume — including when stopping fails, where the banner in Layout is // what surfaces a log level that did not come back down. clearSession(); setStartedAt(null); 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. Absent below the sidebar-compact breakpoint, where the compact header carries the trigger instead — see Layout. */} {showTrigger && ( )} {/* Slide-in panel anchored to bottom-right; a bottom sheet on phones. The desktop geometry cannot be reused there: `w-full` resolves against the viewport for a fixed element, so on a 375px screen the panel was 375px wide and then pushed 16px in from the right, putting its left edge at -16px and cutting a strip of the form off-screen. `max-w-md` hid this on anything above ~464px wide. */} {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 */}