Parcourir la source

[Feature] Redesign bug report debug log capture flow

  Replace fixed 30-second debug log collection with an interactive
  3-step flow: start logging, reproduce the issue, stop & submit.
  Users now control timing instead of racing a countdown.

  Backend: split _collect_debug_logs() into POST /start-logging and
  POST /stop-logging endpoints; add debug_logs field to submit request.
  Frontend: 3-step progress indicator with elapsed timer, pulsing
  active state, and 5-minute auto-stop. Updated all 7 locale files.
maziggy il y a 2 mois
Parent
commit
dc4d77b93a

+ 1 - 0
CHANGELOG.md

@@ -34,6 +34,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Spool Name Column & Filter in Filament Inventory** ([#740](https://github.com/maziggy/bambuddy/issues/740)) — Added a "Spool" column to the filament inventory table that displays the spool catalog entry name (e.g. "Bambu Lab AMS Tray", "Sunlu 1kg"). Enable it via the column visibility menu. Sortable and hidden by default. Also added a spool name filter dropdown next to the brand filter for quick filtering by spool type. Requested by @DMoenning.
 
 ### Changed
+- **Redesigned Bug Report Debug Log Flow** — Replaced the fixed 30-second debug log collection with an interactive 3-step flow: start debug logging, reproduce the issue at your own pace, then stop & submit. An elapsed timer shows recording duration with auto-stop at 5 minutes. Users now have full control over when to capture logs instead of racing a countdown. The backend splits log collection into separate start/stop endpoints, and the frontend shows a step progress indicator with pulsing active state.
 
 ### Improved
 - **Print Command Response Verification** ([#737](https://github.com/maziggy/bambuddy/issues/737)) — After sending a print command, BambuBuddy now monitors whether the printer's state changes within 15 seconds. If the printer silently ignores the command (observed on some P1S firmware versions where the MQTT command handler becomes unresponsive), a warning is logged for diagnostics. This aids debugging when users report prints not starting despite BambuBuddy showing success.

+ 1 - 1
README.md

@@ -198,7 +198,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Debug logging toggle with live indicator
 - Live application log viewer with filtering
 - Support bundle generator with comprehensive diagnostics (privacy-filtered)
-- **In-app bug reporting** — Submit bug reports directly from the UI with optional screenshot (upload, paste, or drag & drop), automatic diagnostic log collection (30s debug capture with printer push), and system info. Reports create GitHub issues via a secure relay. Privacy-first: all logs are sanitized and sensitive data (IPs, serials, credentials) is never included.
+- **In-app bug reporting** — Submit bug reports directly from the UI with optional screenshot (upload, paste, or drag & drop), interactive debug log capture (start logging, reproduce at your own pace, stop & submit), and system info. Reports create GitHub issues via a secure relay. Privacy-first: all logs are sanitized and sensitive data (IPs, serials, credentials) is never included.
 
 ### 🔒 Optional Authentication
 - Enable/disable authentication any time

+ 23 - 18
backend/app/api/routes/bug_report.py

@@ -1,9 +1,8 @@
 """Bug report endpoint for submitting user bug reports to GitHub."""
 
-import asyncio
 import logging
 
-from fastapi import APIRouter
+from fastapi import APIRouter, Query
 from pydantic import BaseModel
 
 from backend.app.api.routes.support import (
@@ -20,14 +19,13 @@ from backend.app.services.printer_manager import printer_manager
 router = APIRouter(prefix="/bug-report", tags=["bug-report"])
 logger = logging.getLogger(__name__)
 
-LOG_COLLECTION_SECONDS = 30
-
 
 class BugReportRequest(BaseModel):
     description: str
     email: str | None = None
     screenshot_base64: str | None = None
     include_support_info: bool = True
+    debug_logs: str | None = None
 
 
 class BugReportResponse(BaseModel):
@@ -37,40 +35,48 @@ class BugReportResponse(BaseModel):
     issue_number: int | None = None
 
 
-async def _collect_debug_logs() -> str:
-    """Enable debug logging, push all printers, wait, then collect logs."""
-    # Check if debug was already enabled
+class StartLoggingResponse(BaseModel):
+    started: bool
+    was_debug: bool
+
+
+class StopLoggingResponse(BaseModel):
+    logs: str
+
+
+@router.post("/start-logging", response_model=StartLoggingResponse)
+async def start_logging():
+    """Enable debug logging and push all printers for fresh data."""
     async with async_session() as db:
         was_debug, _ = await _get_debug_setting(db)
 
-    # Enable debug logging
     if not was_debug:
         async with async_session() as db:
             await _set_debug_setting(db, True)
         _apply_log_level(True)
-        logger.info("Bug report: temporarily enabled debug logging")
+        logger.info("Bug report: enabled debug logging")
 
-    # Send push_all to all connected printers
     for printer_id in list(printer_manager._clients.keys()):
         try:
             printer_manager.request_status_update(printer_id)
         except Exception:
             logger.debug("Failed to push_all for printer %s", printer_id)
 
-    # Wait for logs to accumulate
-    await asyncio.sleep(LOG_COLLECTION_SECONDS)
+    return StartLoggingResponse(started=True, was_debug=was_debug)
+
 
-    # Collect logs
+@router.post("/stop-logging", response_model=StopLoggingResponse)
+async def stop_logging(was_debug: bool = Query(default=False)):
+    """Collect logs and restore previous log level."""
     logs = await _get_recent_sanitized_logs()
 
-    # Restore previous log level if it wasn't debug before
     if not was_debug:
         async with async_session() as db:
             await _set_debug_setting(db, False)
         _apply_log_level(False)
         logger.info("Bug report: restored normal logging")
 
-    return logs
+    return StopLoggingResponse(logs=logs)
 
 
 @router.post("/submit", response_model=BugReportResponse)
@@ -80,9 +86,8 @@ async def submit_bug_report(report: BugReportRequest):
     if report.include_support_info:
         try:
             support_info = await _collect_support_info()
-            logs = await _collect_debug_logs()
-            if logs:
-                support_info["recent_logs"] = logs
+            if report.debug_logs:
+                support_info["recent_logs"] = report.debug_logs
         except Exception:
             logger.exception("Failed to collect support info for bug report")
 

+ 130 - 26
backend/tests/unit/test_bug_report.py

@@ -212,17 +212,16 @@ class TestBugReportService:
         assert "GitHub API error" in result["message"]
 
 
-class TestCollectDebugLogs:
-    """Tests for _collect_debug_logs()."""
+class TestStartLogging:
+    """Tests for the start-logging endpoint handler."""
 
     @pytest.mark.asyncio
     @pytest.mark.unit
     async def test_enables_debug_when_not_already_enabled(self):
-        """Debug logging is enabled, then restored after collection."""
-        from backend.app.api.routes.bug_report import _collect_debug_logs
+        """Debug logging is enabled and printers are pushed."""
+        from backend.app.api.routes.bug_report import start_logging
 
         apply_calls = []
-
         mock_db = AsyncMock()
 
         with (
@@ -234,25 +233,26 @@ class TestCollectDebugLogs:
                 side_effect=lambda v: apply_calls.append(v),
             ),
             patch("backend.app.api.routes.bug_report.printer_manager") as mock_pm,
-            patch("backend.app.api.routes.bug_report._get_recent_sanitized_logs", return_value="DEBUG log line"),
-            patch("backend.app.api.routes.bug_report.asyncio.sleep", new_callable=AsyncMock),
-            patch("backend.app.api.routes.bug_report.LOG_COLLECTION_SECONDS", 0),
         ):
-            mock_pm._clients = {}
+            mock_pm._clients = {"printer1": MagicMock()}
             mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
             mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
 
-            result = await _collect_debug_logs()
+            result = await start_logging()
 
-        assert result == "DEBUG log line"
-        assert apply_calls == [True, False]  # enabled then restored
-        assert mock_set.call_count == 2
+        assert result.started is True
+        assert result.was_debug is False
+        assert apply_calls == [True]
+        mock_set.assert_called_once()
+        mock_pm.request_status_update.assert_called_once_with("printer1")
 
     @pytest.mark.asyncio
     @pytest.mark.unit
     async def test_skips_enable_when_already_debug(self):
         """Debug logging not toggled when already enabled."""
-        from backend.app.api.routes.bug_report import _collect_debug_logs
+        mock_db = AsyncMock()
+
+        from backend.app.api.routes.bug_report import start_logging
 
         with (
             patch("backend.app.api.routes.bug_report.async_session") as mock_session,
@@ -260,18 +260,15 @@ class TestCollectDebugLogs:
             patch("backend.app.api.routes.bug_report._set_debug_setting", new_callable=AsyncMock) as mock_set,
             patch("backend.app.api.routes.bug_report._apply_log_level") as mock_apply,
             patch("backend.app.api.routes.bug_report.printer_manager") as mock_pm,
-            patch("backend.app.api.routes.bug_report._get_recent_sanitized_logs", return_value="logs"),
-            patch("backend.app.api.routes.bug_report.asyncio.sleep", new_callable=AsyncMock),
-            patch("backend.app.api.routes.bug_report.LOG_COLLECTION_SECONDS", 0),
         ):
             mock_pm._clients = {}
-            mock_db = AsyncMock()
             mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
             mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
 
-            result = await _collect_debug_logs()
+            result = await start_logging()
 
-        assert result == "logs"
+        assert result.started is True
+        assert result.was_debug is True
         mock_apply.assert_not_called()
         mock_set.assert_not_called()
 
@@ -279,7 +276,9 @@ class TestCollectDebugLogs:
     @pytest.mark.unit
     async def test_pushes_all_connected_printers(self):
         """Sends status update request to all connected printers."""
-        from backend.app.api.routes.bug_report import _collect_debug_logs
+        mock_db = AsyncMock()
+
+        from backend.app.api.routes.bug_report import start_logging
 
         with (
             patch("backend.app.api.routes.bug_report.async_session") as mock_session,
@@ -287,20 +286,125 @@ class TestCollectDebugLogs:
             patch("backend.app.api.routes.bug_report._set_debug_setting", new_callable=AsyncMock),
             patch("backend.app.api.routes.bug_report._apply_log_level"),
             patch("backend.app.api.routes.bug_report.printer_manager") as mock_pm,
-            patch("backend.app.api.routes.bug_report._get_recent_sanitized_logs", return_value=""),
-            patch("backend.app.api.routes.bug_report.asyncio.sleep", new_callable=AsyncMock),
-            patch("backend.app.api.routes.bug_report.LOG_COLLECTION_SECONDS", 0),
         ):
             mock_pm._clients = {"printer1": MagicMock(), "printer2": MagicMock()}
-            mock_db = AsyncMock()
             mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
             mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
 
-            await _collect_debug_logs()
+            await start_logging()
 
         assert mock_pm.request_status_update.call_count == 2
 
 
+class TestStopLogging:
+    """Tests for the stop-logging endpoint handler."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_collects_logs_and_restores_level(self):
+        """Collects logs and restores log level when was_debug=False."""
+        from backend.app.api.routes.bug_report import stop_logging
+
+        apply_calls = []
+        mock_db = AsyncMock()
+
+        with (
+            patch("backend.app.api.routes.bug_report.async_session") as mock_session,
+            patch("backend.app.api.routes.bug_report._set_debug_setting", new_callable=AsyncMock) as mock_set,
+            patch(
+                "backend.app.api.routes.bug_report._apply_log_level",
+                side_effect=lambda v: apply_calls.append(v),
+            ),
+            patch("backend.app.api.routes.bug_report._get_recent_sanitized_logs", return_value="DEBUG log line"),
+        ):
+            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            result = await stop_logging(was_debug=False)
+
+        assert result.logs == "DEBUG log line"
+        assert apply_calls == [False]
+        mock_set.assert_called_once()
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_skips_restore_when_was_debug(self):
+        """Does not restore log level when was_debug=True."""
+        from backend.app.api.routes.bug_report import stop_logging
+
+        with (
+            patch("backend.app.api.routes.bug_report.async_session") as mock_session,
+            patch("backend.app.api.routes.bug_report._set_debug_setting", new_callable=AsyncMock) as mock_set,
+            patch("backend.app.api.routes.bug_report._apply_log_level") as mock_apply,
+            patch("backend.app.api.routes.bug_report._get_recent_sanitized_logs", return_value="logs"),
+        ):
+            mock_db = AsyncMock()
+            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            result = await stop_logging(was_debug=True)
+
+        assert result.logs == "logs"
+        mock_apply.assert_not_called()
+        mock_set.assert_not_called()
+
+
+class TestSubmitBugReportRoute:
+    """Tests for the submit_bug_report route handler."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_uses_provided_debug_logs(self):
+        """When debug_logs is provided, it is used as recent_logs."""
+        from backend.app.api.routes.bug_report import BugReportRequest, submit_bug_report
+
+        report = BugReportRequest(
+            description="Test bug",
+            debug_logs="pre-collected debug logs",
+        )
+
+        with (
+            patch("backend.app.api.routes.bug_report._collect_support_info", return_value={"version": "1.0"}),
+            patch("backend.app.api.routes.bug_report.submit_report", new_callable=AsyncMock) as mock_submit,
+        ):
+            mock_submit.return_value = {
+                "success": True,
+                "message": "Created",
+                "issue_url": "https://github.com/maziggy/bambuddy/issues/1",
+                "issue_number": 1,
+            }
+
+            result = await submit_bug_report(report)
+
+        assert result.success is True
+        call_kwargs = mock_submit.call_args[1]
+        assert call_kwargs["support_info"]["recent_logs"] == "pre-collected debug logs"
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_no_logs_when_debug_logs_not_provided(self):
+        """When debug_logs is None, recent_logs is not added."""
+        from backend.app.api.routes.bug_report import BugReportRequest, submit_bug_report
+
+        report = BugReportRequest(description="Test bug")
+
+        with (
+            patch("backend.app.api.routes.bug_report._collect_support_info", return_value={"version": "1.0"}),
+            patch("backend.app.api.routes.bug_report.submit_report", new_callable=AsyncMock) as mock_submit,
+        ):
+            mock_submit.return_value = {
+                "success": True,
+                "message": "Created",
+                "issue_url": None,
+                "issue_number": None,
+            }
+
+            await submit_bug_report(report)
+
+        call_kwargs = mock_submit.call_args[1]
+        assert "recent_logs" not in call_kwargs["support_info"]
+
+
 class TestRateLimit:
     """Tests for rate limiting in bug report service."""
 

+ 8 - 5
docker-publish-daily-beta.sh

@@ -327,11 +327,14 @@ ${CHANGELOG_NOTES}
 EOF
     )
 
-    # Delete existing release so the new one gets today's date
-    # (gh release edit only updates title/notes, not the creation timestamp)
-    if gh release view "v${DAILY_TAG}" >/dev/null 2>&1; then
-        echo "  Deleting old release v${DAILY_TAG} (will recreate with today's date)..."
-        gh release delete "v${DAILY_TAG}" --yes --cleanup-tag
+    # Delete ALL old daily releases — only the latest daily build should exist
+    echo "  Cleaning up old daily releases..."
+    OLD_DAILY_RELEASES=$(gh release list --limit 100 --json tagName --jq '.[] | select(.tagName | test("-daily\\.")) | .tagName' 2>/dev/null || true)
+    if [ -n "$OLD_DAILY_RELEASES" ]; then
+        while IFS= read -r old_tag; do
+            echo "  Deleting old daily release: ${old_tag}..."
+            gh release delete "$old_tag" --yes --cleanup-tag 2>/dev/null || true
+        done <<< "$OLD_DAILY_RELEASES"
     fi
 
     # Create/move tag to current HEAD and push

+ 47 - 14
frontend/src/__tests__/components/BugReportBubble.test.tsx

@@ -23,6 +23,17 @@ function getSubmitButton() {
   );
 }
 
+function setupLoggingEndpoints() {
+  server.use(
+    http.post('*/bug-report/start-logging', () => {
+      return HttpResponse.json({ started: true, was_debug: false });
+    }),
+    http.post('*/bug-report/stop-logging', () => {
+      return HttpResponse.json({ logs: 'test debug logs' });
+    })
+  );
+}
+
 describe('BugReportBubble', () => {
   it('renders the floating bug button', () => {
     render(<BugReportBubble />);
@@ -79,16 +90,9 @@ describe('BugReportBubble', () => {
     expect(getSubmitButton()).not.toBeDisabled();
   });
 
-  it('shows collecting state with countdown after submit', async () => {
+  it('shows logging state with step indicators after start', async () => {
     const user = userEvent.setup();
-
-    // Delay the API response so we can see collecting state
-    server.use(
-      http.post('*/bug-report/submit', async () => {
-        await new Promise((resolve) => setTimeout(resolve, 60000));
-        return HttpResponse.json({ success: true, message: 'ok', issue_url: null, issue_number: null });
-      })
-    );
+    setupLoggingEndpoints();
 
     render(<BugReportBubble />);
     await user.click(screen.getByRole('button'));
@@ -98,16 +102,23 @@ describe('BugReportBubble', () => {
     const submitBtn = getSubmitButton();
     if (submitBtn) await user.click(submitBtn);
 
-    // Should show collecting state
+    // Should show step indicators and elapsed timer
+    await waitFor(() => {
+      const reproduceText = screen.queryByText(/reproduce|Reproduce|reproduzieren|reproduire|riproduci|再現|reproduza|重现/i);
+      expect(reproduceText).toBeInTheDocument();
+    });
+
+    // Should show elapsed timer (00:00 format)
     await waitFor(() => {
-      const collectingText = screen.queryByText(/collecting|Collecting|収集|Sammeln|Collecte|Raccolta|Coletando|收集/i);
-      expect(collectingText).toBeInTheDocument();
+      const timer = screen.queryByText(/00:0/);
+      expect(timer).toBeInTheDocument();
     });
   });
 
   it('shows success state after successful submission', async () => {
     const user = userEvent.setup();
 
+    setupLoggingEndpoints();
     server.use(
       http.post('*/bug-report/submit', () => {
         return HttpResponse.json({
@@ -127,17 +138,29 @@ describe('BugReportBubble', () => {
     const submitBtn = getSubmitButton();
     if (submitBtn) await user.click(submitBtn);
 
+    // Wait for logging state, then click stop
+    await waitFor(() => {
+      expect(screen.queryByText(/reproduce|Reproduce|reproduzieren|reproduire|riproduci|再現|reproduza|重现/i)).toBeInTheDocument();
+    });
+
+    // Find and click the Stop & Submit button
+    const stopBtn = screen.getAllByRole('button').find(
+      (b) => b.className.includes('bg-red-500') && !b.className.includes('rounded-full')
+    );
+    if (stopBtn) await user.click(stopBtn);
+
     await waitFor(
       () => {
         expect(screen.getByText(/#42/)).toBeInTheDocument();
       },
-      { timeout: 35000 }
+      { timeout: 10000 }
     );
   });
 
   it('shows error state after failed submission', async () => {
     const user = userEvent.setup();
 
+    setupLoggingEndpoints();
     server.use(
       http.post('*/bug-report/submit', () => {
         return HttpResponse.json({
@@ -157,11 +180,21 @@ describe('BugReportBubble', () => {
     const submitBtn = getSubmitButton();
     if (submitBtn) await user.click(submitBtn);
 
+    // Wait for logging state, then click stop
+    await waitFor(() => {
+      expect(screen.queryByText(/reproduce|Reproduce|reproduzieren|reproduire|riproduci|再現|reproduza|重现/i)).toBeInTheDocument();
+    });
+
+    const stopBtn = screen.getAllByRole('button').find(
+      (b) => b.className.includes('bg-red-500') && !b.className.includes('rounded-full')
+    );
+    if (stopBtn) await user.click(stopBtn);
+
     await waitFor(
       () => {
         expect(screen.getByText(/Relay not available/)).toBeInTheDocument();
       },
-      { timeout: 35000 }
+      { timeout: 10000 }
     );
   });
 

+ 9 - 0
frontend/src/api/client.ts

@@ -5043,6 +5043,7 @@ export interface BugReportRequest {
   email?: string;
   screenshot_base64?: string;
   include_support_info?: boolean;
+  debug_logs?: string;
 }
 
 export interface BugReportResponse {
@@ -5058,4 +5059,12 @@ export const bugReportApi = {
       method: 'POST',
       body: JSON.stringify(data),
     }),
+  startLogging: () =>
+    request<{ started: boolean; was_debug: boolean }>('/bug-report/start-logging', {
+      method: 'POST',
+    }),
+  stopLogging: (wasDebug: boolean) =>
+    request<{ logs: string }>(`/bug-report/stop-logging?was_debug=${wasDebug}`, {
+      method: 'POST',
+    }),
 };

+ 95 - 28
frontend/src/components/BugReportBubble.tsx

@@ -1,14 +1,13 @@
 import { useState, useRef, useCallback, useEffect } from 'react';
-import { Bug, X, Loader2, CheckCircle, AlertCircle, Trash2, Upload } from 'lucide-react';
+import { Bug, X, Loader2, CheckCircle, AlertCircle, Trash2, Upload, Circle, CheckCircle2 } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { bugReportApi } from '../api/client';
 
-type ViewState = 'form' | 'collecting' | 'submitting' | 'success' | 'error';
-
-const LOG_COLLECTION_SECONDS = 30;
+type ViewState = 'form' | 'logging' | 'stopping' | 'submitting' | 'success' | 'error';
 
 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) => {
@@ -34,6 +33,12 @@ function compressImage(file: File): Promise<string> {
   });
 }
 
+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);
@@ -45,20 +50,22 @@ export function BugReportBubble() {
   const [issueUrl, setIssueUrl] = useState<string | null>(null);
   const [issueNumber, setIssueNumber] = useState<number | null>(null);
   const [errorMessage, setErrorMessage] = useState('');
-  const [countdown, setCountdown] = useState(0);
+  const [elapsedSeconds, setElapsedSeconds] = useState(0);
+  const [wasDebug, setWasDebug] = useState(false);
   const modalRef = useRef<HTMLDivElement>(null);
   const fileInputRef = useRef<HTMLInputElement>(null);
+  const handleStopLoggingRef = useRef<() => void>(() => {});
 
-  // Countdown timer for log collection phase
+  // Elapsed timer for logging phase — auto-stop at 5 minutes
   useEffect(() => {
-    if (viewState !== 'collecting') return;
-    if (countdown <= 0) {
-      setViewState('submitting');
+    if (viewState !== 'logging') return;
+    if (elapsedSeconds >= MAX_LOG_SECONDS) {
+      handleStopLoggingRef.current();
       return;
     }
-    const timer = setTimeout(() => setCountdown((c) => c - 1), 1000);
+    const timer = setTimeout(() => setElapsedSeconds((s) => s + 1), 1000);
     return () => clearTimeout(timer);
-  }, [viewState, countdown]);
+  }, [viewState, elapsedSeconds]);
 
   const handleOpen = () => {
     setIsOpen(true);
@@ -69,6 +76,8 @@ export function BugReportBubble() {
     setIssueUrl(null);
     setIssueNumber(null);
     setErrorMessage('');
+    setElapsedSeconds(0);
+    setWasDebug(false);
   };
 
   const handleClose = () => {
@@ -114,16 +123,40 @@ export function BugReportBubble() {
     if (file) handleFile(file);
   }, [handleFile]);
 
-  const handleSubmit = async () => {
+  const handleStartLogging = async () => {
     if (!description.trim()) return;
-    setCountdown(LOG_COLLECTION_SECONDS);
-    setViewState('collecting');
+    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);
@@ -281,30 +314,64 @@ export function BugReportBubble() {
                       {t('common.cancel')}
                     </button>
                     <button
-                      onClick={handleSubmit}
+                      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.submit')}
+                      {t('bugReport.startLogging')}
                     </button>
                   </div>
                 </>
               )}
 
-              {(viewState === 'collecting' || viewState === 'submitting') && (
+              {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-8 gap-3">
                   <Loader2 className="w-8 h-8 animate-spin text-blue-500" />
-                  {viewState === 'collecting' ? (
-                    <>
-                      <p className="text-sm font-medium text-gray-900 dark:text-white">{t('bugReport.collectingLogs')}</p>
-                      <p className="text-xs text-gray-500 dark:text-gray-400">{t('bugReport.collectingLogsHint')}</p>
-                      {countdown > 0 && (
-                        <p className="text-lg font-mono text-blue-500">{t('bugReport.countdownSeconds', { seconds: countdown })}</p>
-                      )}
-                    </>
-                  ) : (
-                    <p className="text-sm text-gray-600 dark:text-gray-400">{t('bugReport.submitting')}</p>
-                  )}
+                  <p className="text-sm text-gray-600 dark:text-gray-400">
+                    {viewState === 'stopping' ? t('bugReport.stoppingLogs') : t('bugReport.submitting')}
+                  </p>
                 </div>
               )}
 

+ 7 - 3
frontend/src/i18n/locales/de.ts

@@ -4525,8 +4525,13 @@ export default {
     dataNeverIncluded: 'Nie enthalten:',
     dataNeverIncludedList: 'Druckernamen, Seriennummern, Zugangscodes, Passwörter, IP-Adressen, E-Mail-Adressen, API-Schlüssel, Tokens, Webhook-URLs, Hostnamen oder Benutzernamen.',
     submit: 'Absenden',
-    collectingLogs: 'Diagnoseprotokolle werden gesammelt...',
-    collectingLogsHint: 'Debug-Protokollierung aktiviert, Drucker werden nach aktuellen Daten abgefragt.',
+    startLogging: 'Debug-Protokollierung starten',
+    stepEnableLogging: 'Debug-Protokollierung aktiviert',
+    stepReproduce: 'Problem jetzt reproduzieren',
+    stepStopLogging: 'Stoppen & Bericht senden',
+    stopAndSubmit: 'Stoppen & Senden',
+    maxDuration: 'Stoppt automatisch nach {{minutes}} Min.',
+    stoppingLogs: 'Protokolle sammeln & senden...',
     submitting: 'Fehlerbericht wird gesendet...',
     submitSuccess: 'Fehlerbericht erfolgreich gesendet!',
     submitFailed: 'Fehlerbericht konnte nicht gesendet werden',
@@ -4534,6 +4539,5 @@ export default {
     submitted: 'Ihr Fehlerbericht wurde eingereicht.',
     viewIssue: 'Issue ansehen',
     unexpectedError: 'Ein unerwarteter Fehler ist aufgetreten',
-    countdownSeconds: '{{seconds}}s',
   },
 };

+ 7 - 3
frontend/src/i18n/locales/en.ts

@@ -4530,8 +4530,13 @@ export default {
     dataNeverIncluded: 'Never included:',
     dataNeverIncludedList: 'Printer names, serial numbers, access codes, passwords, IP addresses, email addresses, API keys, tokens, webhook URLs, hostnames, or usernames.',
     submit: 'Submit',
-    collectingLogs: 'Collecting diagnostic logs...',
-    collectingLogsHint: 'Debug logging enabled, querying printers for fresh data.',
+    startLogging: 'Start Debug Logging',
+    stepEnableLogging: 'Debug logging enabled',
+    stepReproduce: 'Reproduce the issue now',
+    stepStopLogging: 'Stop & submit report',
+    stopAndSubmit: 'Stop & Submit',
+    maxDuration: 'Auto-stops after {{minutes}} min',
+    stoppingLogs: 'Collecting logs & submitting...',
     submitting: 'Submitting bug report...',
     submitSuccess: 'Bug report submitted successfully!',
     submitFailed: 'Failed to submit bug report',
@@ -4539,6 +4544,5 @@ export default {
     submitted: 'Your bug report has been submitted.',
     viewIssue: 'View Issue',
     unexpectedError: 'An unexpected error occurred',
-    countdownSeconds: '{{seconds}}s',
   },
 };

+ 7 - 3
frontend/src/i18n/locales/fr.ts

@@ -4517,8 +4517,13 @@ export default {
     dataNeverIncluded: 'Jamais inclus :',
     dataNeverIncludedList: 'Noms d\'imprimantes, numéros de série, codes d\'accès, mots de passe, adresses IP, adresses e-mail, clés API, tokens, URLs de webhook, noms d\'hôtes ou noms d\'utilisateurs.',
     submit: 'Envoyer',
-    collectingLogs: 'Collecte des journaux de diagnostic...',
-    collectingLogsHint: 'Journalisation de débogage activée, interrogation des imprimantes pour des données fraîches.',
+    startLogging: 'Lancer la journalisation',
+    stepEnableLogging: 'Journalisation activée',
+    stepReproduce: 'Reproduisez le problème',
+    stepStopLogging: 'Arrêter & envoyer',
+    stopAndSubmit: 'Arrêter & Envoyer',
+    maxDuration: 'Arrêt auto après {{minutes}} min',
+    stoppingLogs: 'Collecte des journaux & envoi...',
     submitting: 'Envoi du rapport de bug...',
     submitSuccess: 'Rapport de bug envoyé avec succès !',
     submitFailed: 'Échec de l\'envoi du rapport de bug',
@@ -4526,6 +4531,5 @@ export default {
     submitted: 'Votre rapport de bug a été soumis.',
     viewIssue: 'Voir l\'issue',
     unexpectedError: 'Une erreur inattendue est survenue',
-    countdownSeconds: '{{seconds}}s',
   },
 };

+ 7 - 3
frontend/src/i18n/locales/it.ts

@@ -4516,8 +4516,13 @@ export default {
     dataNeverIncluded: 'Mai inclusi:',
     dataNeverIncludedList: 'Nomi stampanti, numeri di serie, codici di accesso, password, indirizzi IP, indirizzi email, chiavi API, token, URL webhook, nomi host o nomi utente.',
     submit: 'Invia',
-    collectingLogs: 'Raccolta dei log diagnostici...',
-    collectingLogsHint: 'Registrazione debug attivata, interrogazione delle stampanti per dati aggiornati.',
+    startLogging: 'Avvia registrazione debug',
+    stepEnableLogging: 'Registrazione debug attivata',
+    stepReproduce: 'Riproduci il problema ora',
+    stepStopLogging: 'Ferma & invia rapporto',
+    stopAndSubmit: 'Ferma & Invia',
+    maxDuration: 'Arresto automatico dopo {{minutes}} min',
+    stoppingLogs: 'Raccolta log & invio...',
     submitting: 'Invio segnalazione bug...',
     submitSuccess: 'Segnalazione bug inviata con successo!',
     submitFailed: 'Impossibile inviare la segnalazione bug',
@@ -4525,6 +4530,5 @@ export default {
     submitted: 'La tua segnalazione bug è stata inviata.',
     viewIssue: 'Vedi issue',
     unexpectedError: 'Si è verificato un errore imprevisto',
-    countdownSeconds: '{{seconds}}s',
   },
 };

+ 7 - 3
frontend/src/i18n/locales/ja.ts

@@ -4529,8 +4529,13 @@ export default {
     dataNeverIncluded: '含まれないもの:',
     dataNeverIncludedList: 'プリンター名、シリアル番号、アクセスコード、パスワード、IPアドレス、メールアドレス、APIキー、トークン、Webhook URL、ホスト名、ユーザー名。',
     submit: '送信',
-    collectingLogs: '診断ログを収集中...',
-    collectingLogsHint: 'デバッグログを有効化し、プリンターから最新データを取得しています。',
+    startLogging: 'デバッグログ開始',
+    stepEnableLogging: 'デバッグログ有効',
+    stepReproduce: '問題を再現してください',
+    stepStopLogging: '停止してレポート送信',
+    stopAndSubmit: '停止して送信',
+    maxDuration: '{{minutes}}分後に自動停止',
+    stoppingLogs: 'ログ収集・送信中...',
     submitting: 'バグレポートを送信中...',
     submitSuccess: 'バグレポートが正常に送信されました!',
     submitFailed: 'バグレポートの送信に失敗しました',
@@ -4538,6 +4543,5 @@ export default {
     submitted: 'バグレポートが送信されました。',
     viewIssue: 'Issueを表示',
     unexpectedError: '予期しないエラーが発生しました',
-    countdownSeconds: '{{seconds}}秒',
   },
 };

+ 7 - 3
frontend/src/i18n/locales/pt-BR.ts

@@ -4516,8 +4516,13 @@ export default {
     dataNeverIncluded: 'Nunca incluídos:',
     dataNeverIncludedList: 'Nomes de impressoras, números de série, códigos de acesso, senhas, endereços IP, endereços de email, chaves de API, tokens, URLs de webhook, nomes de host ou nomes de usuário.',
     submit: 'Enviar',
-    collectingLogs: 'Coletando logs de diagnóstico...',
-    collectingLogsHint: 'Log de depuração ativado, consultando impressoras para dados atualizados.',
+    startLogging: 'Iniciar log de depuração',
+    stepEnableLogging: 'Log de depuração ativado',
+    stepReproduce: 'Reproduza o problema agora',
+    stepStopLogging: 'Parar & enviar relatório',
+    stopAndSubmit: 'Parar & Enviar',
+    maxDuration: 'Para automaticamente após {{minutes}} min',
+    stoppingLogs: 'Coletando logs & enviando...',
     submitting: 'Enviando relatório de bug...',
     submitSuccess: 'Relatório de bug enviado com sucesso!',
     submitFailed: 'Falha ao enviar relatório de bug',
@@ -4525,6 +4530,5 @@ export default {
     submitted: 'Seu relatório de bug foi enviado.',
     viewIssue: 'Ver issue',
     unexpectedError: 'Ocorreu um erro inesperado',
-    countdownSeconds: '{{seconds}}s',
   },
 };

+ 7 - 3
frontend/src/i18n/locales/zh-CN.ts

@@ -4515,8 +4515,13 @@ export default {
     dataNeverIncluded: '绝不包含:',
     dataNeverIncludedList: '打印机名称、序列号、访问代码、密码、IP地址、邮箱地址、API密钥、令牌、Webhook URL、主机名或用户名。',
     submit: '提交',
-    collectingLogs: '正在收集诊断日志...',
-    collectingLogsHint: '已启用调试日志,正在查询打印机获取最新数据。',
+    startLogging: '开始调试日志',
+    stepEnableLogging: '调试日志已启用',
+    stepReproduce: '请现在重现问题',
+    stepStopLogging: '停止并提交报告',
+    stopAndSubmit: '停止并提交',
+    maxDuration: '{{minutes}}分钟后自动停止',
+    stoppingLogs: '正在收集日志并提交...',
     submitting: '正在提交错误报告...',
     submitSuccess: '错误报告提交成功!',
     submitFailed: '提交错误报告失败',
@@ -4524,6 +4529,5 @@ export default {
     submitted: '您的错误报告已提交。',
     viewIssue: '查看Issue',
     unexpectedError: '发生了意外错误',
-    countdownSeconds: '{{seconds}}秒',
   },
 };

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-DI9VPacx.css


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-Dwo_5Mjd.js


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Ce8mhR3n.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-hPK_4Ftq.css">
+    <script type="module" crossorigin src="/assets/index-Dwo_5Mjd.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DI9VPacx.css">
   </head>
   <body>
     <div id="root"></div>

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff