mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
The three diagnostic surfaces shipped earlier this month (6bc6a1d6VP setup diagnostic,e222a0eflog-health scanner,ed31b8f4connection diagnostic in the bug-report bubble) were only ever shown to the *user*. A bug report arriving in the maintainer's inbox carried raw logs but no diagnostic results — the user-visible "your X1C can't reach MQTT" finding never made it into the issue body, so the maintainer had to ask the user to re-run and paste. New `services/diagnostic_snapshot.collect_diagnostic_snapshot` runs all three concurrently with a per-probe 15 s wall-clock cap (so total ≈ max(per-cap), not sum — fleet size doesn't matter) and is fail-soft per probe: a crash inside one printer's check emits `{"printer_id": N, "error": "..."}` for that entry rather than nuking the whole snapshot. The snapshot is then added as a `diagnostics` top-level key by `_collect_support_info()`, so both flows (POST /support/bundle and POST /bug-report/submit via `support_info=...`) pick it up without their own changes. Private-data sanitization ------------------------- The diagnostic schemas embed raw IPv4 in five field shapes that must not land in a submitted GitHub issue or a shared support ZIP: - PrinterDiagnosticResult.ip_address (top-level) - DiagnosticCheck.params.printer_ip (network-mode check) - DiagnosticCheck.params.host_ip (network-mode check) - VPDiagnosticResult per-check params.bind_ip (VP setup) - IPs embedded in log-health sample lines The first two carry the printer's own IP (already in the existing `collect_sensitive_strings` table via the Printer rows); host_ip and bind_ip are NOT in the DB so a sensitive_strings-only pass missed them. Fix: `_sanitize_recursive` walks the full snapshot tree, masks DB-known values with the same `[PRINTER]/[IP]/[SERIAL]/[ACCESS_CODE]` labels the log sanitizer applies (via the shared `collect_sensitive_strings`), then an IPv4-regex pass catches any IP the DB didn't cover — most importantly the Bambuddy host IP returned by `_get_host_ip()` and the VP `bind_ip` the user picked at setup. Recursive walk so arbitrary nested dicts/lists don't slip through future schema additions. Live-DB smoke test against the dev fleet: zero raw IPv4 instances in the serialized snapshot output; all five field shapes plus the embedded log samples render as `[IP]`. Progress indicators ------------------- The bubble's "submitting" view and the System page's Download button now render a static four-line checklist showing what's running (printer connectivity → VP setup → log scan → submit / build ZIP). Static, not faked phase progress — we can't actually track server-side phases without SSE and the honest "here's what's happening" list communicates the longer wait without lying about percentage complete. 9 new i18n keys, real translations in all 9 locales (no English fallback). parity script clean at 4993 leaves per locale. Tests: 6 new in test_diagnostic_snapshot.py - empty-input shape stable (the three top-level keys always present) - per-printer / per-VP result coverage (lists match input lengths) - fail-soft on a single-probe crash (other entries + log-health still complete) - timed_out marker when a probe exceeds the per-probe cap (test patches the cap to 0.05 s) - end-to-end IP sanitization across all five field shapes plus log-sample IPs, with a final JSON-serialize-and-regex sweep asserting zero raw IPv4 escapes anywhere in the result - concurrent execution proof (4 × 0.2 s probes complete in < 0.5 s; sequential would be 0.8 s)
563 lines
26 KiB
TypeScript
563 lines
26 KiB
TypeScript
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<string> {
|
|
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<ViewState>('form');
|
|
const [description, setDescription] = useState('');
|
|
const [email, setEmail] = useState('');
|
|
const [screenshot, setScreenshot] = useState<string | null>(null);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [issueUrl, setIssueUrl] = useState<string | null>(null);
|
|
const [issueNumber, setIssueNumber] = useState<number | null>(null);
|
|
const [errorMessage, setErrorMessage] = useState('');
|
|
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
|
const [wasDebug, setWasDebug] = useState(false);
|
|
const modalRef = useRef<HTMLDivElement>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(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<DiagnosticEntry[]> => {
|
|
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 */}
|
|
<button
|
|
onClick={handleOpen}
|
|
className="fixed bottom-4 right-4 z-40 w-12 h-12 rounded-full bg-red-500 hover:bg-red-600 text-white shadow-lg hover:shadow-xl transition-all duration-200 hover:scale-110 flex items-center justify-center"
|
|
title={t('bugReport.title')}
|
|
>
|
|
<Bug className="w-5 h-5" />
|
|
</button>
|
|
|
|
{/* Slide-in panel anchored to bottom-right */}
|
|
{isOpen && (
|
|
<div
|
|
id="bug-report-modal"
|
|
className="fixed bottom-20 right-4 z-50 w-full max-w-md"
|
|
onPaste={handlePaste}
|
|
>
|
|
<div
|
|
ref={modalRef}
|
|
className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl border border-gray-200 dark:border-gray-700 max-h-[80vh] overflow-y-auto"
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 sticky top-0 bg-white dark:bg-gray-800 z-10">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
|
<Bug className="w-5 h-5 text-red-500" />
|
|
{t('bugReport.title')}
|
|
</h2>
|
|
<button
|
|
onClick={handleClose}
|
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-4 space-y-4">
|
|
{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 && (
|
|
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
{t('bugReport.diagnosticChecking')}
|
|
</div>
|
|
)}
|
|
{!diagnosticScan.isLoading && diagnosticProblems.length > 0 && (
|
|
<div className="rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-3 space-y-3">
|
|
<div className="flex items-start gap-2">
|
|
<Stethoscope className="w-4 h-4 mt-0.5 flex-shrink-0 text-amber-600 dark:text-amber-400" />
|
|
<div>
|
|
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
|
|
{t('bugReport.diagnosticSummary', {
|
|
problems: diagnosticProblems.length,
|
|
total: diagnosticEntries.length,
|
|
})}
|
|
</p>
|
|
<p className="text-xs text-amber-800 dark:text-amber-200 mt-0.5">
|
|
{t('bugReport.diagnosticIntro')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{diagnosticProblems.map((entry) => (
|
|
<Collapsible
|
|
key={entry.result.printer_id ?? entry.result.ip_address}
|
|
defaultOpen={diagnosticProblems.length === 1}
|
|
className="rounded-lg bg-amber-100/60 dark:bg-amber-900/30 px-3 py-2"
|
|
summary={
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<AlertTriangle className="w-4 h-4 flex-shrink-0 text-amber-600 dark:text-amber-400" />
|
|
<span className="text-sm font-medium text-amber-800 dark:text-amber-200 truncate">
|
|
{entry.name}
|
|
</span>
|
|
</div>
|
|
}
|
|
>
|
|
<DiagnosticChecklist result={entry.result} />
|
|
</Collapsible>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{!diagnosticScan.isLoading &&
|
|
diagnosticEntries.length > 0 &&
|
|
diagnosticProblems.length === 0 && (
|
|
<div className="flex items-start gap-2 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 p-3">
|
|
<CheckCircle className="w-4 h-4 mt-0.5 flex-shrink-0 text-green-600 dark:text-green-400" />
|
|
<p className="text-xs text-green-800 dark:text-green-200">
|
|
{t('bugReport.diagnosticHealthy')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Log-health scan — known issues found in recent logs.
|
|
Shown only when something matched. */}
|
|
{!logHealthScan.isLoading && logFindings.length > 0 && logHealthScan.data && (
|
|
<div className="rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-3 space-y-3">
|
|
<div className="flex items-start gap-2">
|
|
<Stethoscope className="w-4 h-4 mt-0.5 flex-shrink-0 text-amber-600 dark:text-amber-400" />
|
|
<div>
|
|
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
|
|
{t('bugReport.logHealthSummary')}
|
|
</p>
|
|
<p className="text-xs text-amber-800 dark:text-amber-200 mt-0.5">
|
|
{t('bugReport.logHealthIntro')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<SystemHealthPanel result={logHealthScan.data} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Description */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
{t('bugReport.description')} *
|
|
</label>
|
|
<textarea
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder={t('bugReport.descriptionPlaceholder')}
|
|
rows={3}
|
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-vertical"
|
|
/>
|
|
</div>
|
|
|
|
{/* Email (optional) */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
{t('bugReport.email')}
|
|
</label>
|
|
<input
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
placeholder={t('bugReport.emailPlaceholder')}
|
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
/>
|
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
{t('bugReport.emailPrivacy')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Screenshot — upload, paste, or drag */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
{t('bugReport.screenshot')}
|
|
</label>
|
|
{screenshot ? (
|
|
<div className="relative">
|
|
<img
|
|
src={`data:image/jpeg;base64,${screenshot}`}
|
|
alt={t('bugReport.screenshot')}
|
|
className="w-full max-h-40 object-contain rounded-lg border border-gray-200 dark:border-gray-600"
|
|
/>
|
|
<button
|
|
onClick={() => setScreenshot(null)}
|
|
className="absolute top-2 right-2 p-1 bg-red-500 hover:bg-red-600 text-white rounded-full shadow"
|
|
title={t('common.delete')}
|
|
>
|
|
<Trash2 className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
className={`w-full flex flex-col items-center gap-2 px-4 py-4 border-2 border-dashed rounded-lg transition-colors cursor-pointer ${
|
|
isDragging
|
|
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-500'
|
|
: 'border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 hover:border-gray-400 dark:hover:border-gray-500 hover:text-gray-600 dark:hover:text-gray-300'
|
|
}`}
|
|
>
|
|
<Upload className="w-5 h-5" />
|
|
<span className="text-sm">{t('bugReport.uploadOrPaste')}</span>
|
|
</button>
|
|
)}
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) handleFile(file);
|
|
e.target.value = '';
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Data collection notice */}
|
|
<details className="text-xs bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
|
<summary className="cursor-pointer font-medium text-amber-700 dark:text-amber-300 hover:text-amber-800 dark:hover:text-amber-200">
|
|
{t('bugReport.dataCollectedSummary')}
|
|
</summary>
|
|
<div className="mt-2 space-y-2 pl-2 border-l-2 border-amber-300 dark:border-amber-700 text-amber-800 dark:text-amber-200">
|
|
<p className="font-medium">{t('bugReport.dataIncluded')}</p>
|
|
<p>{t('bugReport.dataIncludedList')}</p>
|
|
<p className="font-medium">{t('bugReport.dataNeverIncluded')}</p>
|
|
<p>{t('bugReport.dataNeverIncludedList')}</p>
|
|
</div>
|
|
</details>
|
|
|
|
{/* Buttons */}
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<button
|
|
onClick={handleClose}
|
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
|
>
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button
|
|
onClick={handleStartLogging}
|
|
disabled={!description.trim()}
|
|
className="px-4 py-2 text-sm font-medium text-white bg-red-500 hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg transition-colors"
|
|
>
|
|
{t('bugReport.startLogging')}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{viewState === 'logging' && (
|
|
<div className="py-6 space-y-6">
|
|
{/* 3-step progress indicator */}
|
|
<div className="space-y-3 px-2">
|
|
{/* Step 1: Completed */}
|
|
<div className="flex items-center gap-3">
|
|
<CheckCircle2 className="w-5 h-5 text-green-500 flex-shrink-0" />
|
|
<span className="text-sm text-green-700 dark:text-green-400">{t('bugReport.stepEnableLogging')}</span>
|
|
</div>
|
|
{/* Step 2: Active */}
|
|
<div className="flex items-center gap-3">
|
|
<span className="relative flex h-5 w-5 flex-shrink-0 items-center justify-center">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75"></span>
|
|
<span className="relative inline-flex rounded-full h-3 w-3 bg-blue-500"></span>
|
|
</span>
|
|
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">{t('bugReport.stepReproduce')}</span>
|
|
</div>
|
|
{/* Step 3: Upcoming */}
|
|
<div className="flex items-center gap-3">
|
|
<Circle className="w-5 h-5 text-gray-300 dark:text-gray-600 flex-shrink-0" />
|
|
<span className="text-sm text-gray-400 dark:text-gray-500">{t('bugReport.stepStopLogging')}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Elapsed timer */}
|
|
<div className="text-center">
|
|
<p className="text-3xl font-mono text-blue-500">{formatElapsed(elapsedSeconds)}</p>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{t('bugReport.maxDuration', { minutes: 5 })}</p>
|
|
</div>
|
|
|
|
{/* Stop & Submit button */}
|
|
<div className="flex justify-center">
|
|
<button
|
|
onClick={handleStopLogging}
|
|
className="px-6 py-2.5 text-sm font-medium text-white bg-red-500 hover:bg-red-600 rounded-lg transition-colors"
|
|
>
|
|
{t('bugReport.stopAndSubmit')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{(viewState === 'stopping' || viewState === 'submitting') && (
|
|
<div className="flex flex-col items-center justify-center py-6 gap-3">
|
|
<Loader2 className="w-8 h-8 animate-spin text-blue-500" />
|
|
<p className="text-sm text-gray-600 dark:text-gray-400 text-center">
|
|
{viewState === 'stopping' ? t('bugReport.stoppingLogs') : t('bugReport.submitting')}
|
|
</p>
|
|
{viewState === 'submitting' && (
|
|
// Diagnostics are run server-side inside the submit call
|
|
// (#1506 follow-up): the bubble already displays current
|
|
// results inline, but the submitted report now also
|
|
// includes a snapshot. Wait is bounded but noticeable —
|
|
// list what's running so the user knows why.
|
|
<ul className="text-xs text-gray-500 dark:text-gray-400 list-disc list-inside space-y-0.5">
|
|
<li>{t('bugReport.submittingStepConnection')}</li>
|
|
<li>{t('bugReport.submittingStepVirtualPrinters')}</li>
|
|
<li>{t('bugReport.submittingStepLogScan')}</li>
|
|
<li>{t('bugReport.submittingStepSubmit')}</li>
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{viewState === 'success' && (
|
|
<div className="flex flex-col items-center justify-center py-8 gap-3">
|
|
<CheckCircle className="w-12 h-12 text-green-500" />
|
|
<p className="text-lg font-semibold text-gray-900 dark:text-white">{t('bugReport.thankYou')}</p>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">{t('bugReport.submitted')}</p>
|
|
{issueUrl && (
|
|
<a
|
|
href={issueUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-sm text-blue-500 hover:text-blue-600 underline"
|
|
>
|
|
{t('bugReport.viewIssue')} #{issueNumber}
|
|
</a>
|
|
)}
|
|
<button
|
|
onClick={handleClose}
|
|
className="mt-4 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
|
>
|
|
{t('common.close')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{viewState === 'error' && (
|
|
<div className="flex flex-col items-center justify-center py-8 gap-3">
|
|
<AlertCircle className="w-12 h-12 text-red-500" />
|
|
<p className="text-lg font-semibold text-gray-900 dark:text-white">{t('bugReport.submitFailed')}</p>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400 text-center">{errorMessage}</p>
|
|
<div className="flex gap-2 mt-4">
|
|
<button
|
|
onClick={() => setViewState('form')}
|
|
className="px-4 py-2 text-sm font-medium text-white bg-red-500 hover:bg-red-600 rounded-lg transition-colors"
|
|
>
|
|
{t('bugReport.submit')}
|
|
</button>
|
|
<button
|
|
onClick={handleClose}
|
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
|
>
|
|
{t('common.close')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|