Kaynağa Gözat

Add in-app bug reporting with relay, debug log collection, and privacy controls

  Floating bug report button submits issues via bambuddy.cool relay (no GitHub
  token needed locally). Collects 30s debug logs with printer push_all, sanitizes
  all sensitive data, uploads logs as files to GitHub. Screenshot upload/paste/drag
  with JPEG compression. Translated into all 7 languages. Includes 21 tests.
maziggy 2 ay önce
ebeveyn
işleme
058f74a7da

+ 1 - 0
CHANGELOG.md

@@ -13,6 +13,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Ethernet Connection Indicator** ([#585](https://github.com/maziggy/bambuddy/issues/585)) — Printers connected via ethernet now show a green "Ethernet" badge with a cable icon instead of the WiFi signal strength indicator. Detected via `home_flag` bit 18 from the printer's MQTT data. The printer info modal also shows "Ethernet" instead of WiFi signal details.
 
 ### New Features
+- **In-App Bug Reporting** — A floating bug report button in the bottom-right corner lets users submit bug reports directly from the Bambuddy UI. Reports include a description, optional screenshot (upload, paste, or drag & drop with automatic JPEG compression), optional contact email, and automatically collected diagnostic data. On submit, the system temporarily enables debug logging, sends push_all to all connected printers, waits 30 seconds to collect fresh logs, then submits everything to a secure relay on bambuddy.cool which creates a GitHub issue with sanitized logs uploaded as a separate file. All sensitive data (printer names, serial numbers, IPs, credentials, email addresses) is redacted from logs before submission. The expandable data privacy notice details exactly what is and isn't collected. Translated into all 7 supported languages.
 - **SpoolBuddy NFC Tag Writing (OpenTag3D)** — SpoolBuddy can now write NFC tags for third-party filament spools using the OpenTag3D format on NTAG213/215/216 stickers. A new "Write" page (`/spoolbuddy/write-tag`) in the kiosk UI provides three workflows: write a tag for an existing inventory spool (no tag linked yet), create a new spool and write in one flow, or replace a damaged tag (unlinks old, writes new). The left panel shows a searchable spool list or a compact creation form (material dropdown, color picker, brand, weight); the right panel shows real-time NFC status with tag detection, a spool summary, and the write button. The backend encodes spool data as a 133-byte OpenTag3D NDEF message (MIME type `application/opentag3d`, fits NTAG213's 144-byte capacity) containing material, color, brand, weight, temperature, and RGBA color data. The write command flows through the existing heartbeat polling mechanism — the frontend queues a write, the daemon picks it up on the next heartbeat, writes page-by-page with read-back verification via the PN5180's NTAG WRITE (0xA2) command, and reports success/failure via WebSocket. On success the tag UID is automatically linked to the spool with `data_origin=opentag3d`. Written tags are readable by any OpenTag3D-compatible reader including SpoolBuddy itself. Translations added for all 6 languages.
 - **SpoolBuddy On-Screen Keyboard** — Added a virtual QWERTY keyboard for the SpoolBuddy kiosk UI (and login page) since the Raspberry Pi has no physical keyboard and system-level virtual keyboards (squeekboard, wvkbd) don't auto-show/hide in the labwc/Chromium kiosk environment. Uses `react-simple-keyboard` with a dark theme matching the bambu-dark/bambu-green palette. Auto-shows when any text/password/email input is focused, supports shift, caps lock, backspace, and email-friendly keys (@, .). Inputs with `data-vkb="false"` are excluded (e.g. SpoolBuddySettingsPage's own numpad). A two-phase close prevents ghost-click passthrough to elements underneath the keyboard.
 - **SpoolBuddy Inline Spool Cards** — Placing an NFC-tagged spool on the SpoolBuddy reader now shows spool info directly in the dashboard's right panel instead of a separate modal overlay. Known spools display a SpoolIcon with color/brand/material, a large remaining-weight readout with fill bar, and a weight comparison grid, with action buttons for "Assign to AMS", "Sync Weight", and "Close". Unknown tags show the tag UID, scale weight, and offer "Add to Inventory" or "Link to Spool" actions. The card stays visible if the tag is removed (for continued interaction) and won't re-appear for the same tag after dismissal — but re-placing a tag after removal shows it again. The idle spool animation displays when no tag is detected.

+ 1 - 0
README.md

@@ -191,6 +191,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.
 
 ### 🔒 Optional Authentication
 - Enable/disable authentication any time

+ 95 - 0
backend/app/api/routes/bug_report.py

@@ -0,0 +1,95 @@
+"""Bug report endpoint for submitting user bug reports to GitHub."""
+
+import asyncio
+import logging
+
+from fastapi import APIRouter
+from pydantic import BaseModel
+
+from backend.app.api.routes.support import (
+    _apply_log_level,
+    _collect_support_info,
+    _get_debug_setting,
+    _get_recent_sanitized_logs,
+    _set_debug_setting,
+)
+from backend.app.core.database import async_session
+from backend.app.services.bug_report import submit_report
+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
+
+
+class BugReportResponse(BaseModel):
+    success: bool
+    message: str
+    issue_url: str | None = None
+    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
+    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")
+
+    # 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)
+
+    # Collect logs
+    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
+
+
+@router.post("/submit", response_model=BugReportResponse)
+async def submit_bug_report(report: BugReportRequest):
+    """Submit a bug report. No auth required — anyone should be able to report bugs."""
+    support_info = None
+    if report.include_support_info:
+        try:
+            support_info = await _collect_support_info()
+            logs = await _collect_debug_logs()
+            if logs:
+                support_info["recent_logs"] = logs
+        except Exception:
+            logger.exception("Failed to collect support info for bug report")
+
+    result = await submit_report(
+        description=report.description,
+        reporter_email=report.email,
+        screenshot_base64=report.screenshot_base64,
+        support_info=support_info,
+    )
+    return BugReportResponse(**result)

+ 39 - 0
backend/app/api/routes/support.py

@@ -779,6 +779,45 @@ def _get_log_content(max_bytes: int = 10 * 1024 * 1024, sensitive_strings: dict[
     return content.encode("utf-8")
 
 
+async def _get_recent_sanitized_logs(max_lines: int = 200) -> str:
+    """Get recent log lines, sanitized for inclusion in bug reports."""
+    # Collect sensitive strings from DB for redaction
+    sensitive_strings: dict[str, str] = {}
+    async with async_session() as db:
+        result = await db.execute(select(Printer.name, Printer.serial_number, Printer.ip_address))
+        for name, serial, ip_address in result.all():
+            if name:
+                sensitive_strings[name] = "[PRINTER]"
+            if serial:
+                sensitive_strings[serial] = "[SERIAL]"
+            if ip_address:
+                sensitive_strings[ip_address] = "[IP]"
+
+        result = await db.execute(select(User.username))
+        for (username,) in result.all():
+            if username:
+                sensitive_strings[username] = "[USER]"
+
+        result = await db.execute(select(Settings.value).where(Settings.key == "bambu_cloud_email"))
+        cloud_email = result.scalar_one_or_none()
+        if cloud_email:
+            sensitive_strings[cloud_email] = "[EMAIL]"
+
+    log_file = settings.log_dir / "bambuddy.log"
+    if not log_file.exists():
+        return ""
+
+    # Read last portion of log file
+    try:
+        content = log_file.read_text(encoding="utf-8", errors="replace")
+        lines = content.splitlines()
+        recent = "\n".join(lines[-max_lines:])
+        return _sanitize_log_content(recent, sensitive_strings)
+    except Exception:
+        logger.debug("Failed to read logs for bug report", exc_info=True)
+        return ""
+
+
 @router.get("/bundle")
 async def generate_support_bundle(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),

+ 1 - 0
backend/app/core/config.py

@@ -7,6 +7,7 @@ from pydantic_settings import BaseSettings
 # Application version - single source of truth
 APP_VERSION = "0.2.2b1"
 GITHUB_REPO = "maziggy/bambuddy"
+BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
 
 # App directory - where the application is installed (for static files)
 _app_dir = Path(__file__).resolve().parent.parent.parent.parent

+ 1 - 0
backend/app/core/database.py

@@ -76,6 +76,7 @@ async def init_db():
         ams_history,
         api_key,
         archive,
+        bug_report,
         color_catalog,
         external_link,
         filament,

+ 2 - 0
backend/app/main.py

@@ -16,6 +16,7 @@ from backend.app.api.routes import (
     archives,
     auth,
     background_dispatch as background_dispatch_routes,
+    bug_report,
     camera,
     cloud,
     discovery,
@@ -3583,6 +3584,7 @@ async def auth_middleware(request, call_next):
 
 # API routes
 app.include_router(auth.router, prefix=app_settings.api_prefix)
+app.include_router(bug_report.router, prefix=app_settings.api_prefix)
 app.include_router(users.router, prefix=app_settings.api_prefix)
 app.include_router(groups.router, prefix=app_settings.api_prefix)
 app.include_router(printers.router, prefix=app_settings.api_prefix)

+ 20 - 0
backend/app/models/bug_report.py

@@ -0,0 +1,20 @@
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, Integer, String, Text, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class BugReport(Base):
+    __tablename__ = "bug_reports"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    description: Mapped[str] = mapped_column(Text)
+    reporter_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
+    github_issue_number: Mapped[int | None] = mapped_column(Integer, nullable=True)
+    github_issue_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
+    status: Mapped[str] = mapped_column(String(20), default="submitted")
+    error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
+    email_sent: Mapped[bool] = mapped_column(Boolean, default=False)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

+ 142 - 0
backend/app/services/bug_report.py

@@ -0,0 +1,142 @@
+"""Bug report service — posts to the bambuddy.cool relay which holds the GitHub PAT."""
+
+import logging
+import time
+
+import httpx
+
+from backend.app.core.config import BUG_REPORT_RELAY_URL
+from backend.app.core.database import async_session
+from backend.app.models.bug_report import BugReport
+
+logger = logging.getLogger(__name__)
+
+# Rate limiting: max 5 reports per hour
+_rate_limit_window = 3600
+_rate_limit_max = 5
+_rate_limit_timestamps: list[float] = []
+
+
+def _check_rate_limit() -> bool:
+    """Check if rate limit allows a new report. Returns True if allowed."""
+    now = time.time()
+    _rate_limit_timestamps[:] = [t for t in _rate_limit_timestamps if now - t < _rate_limit_window]
+    if len(_rate_limit_timestamps) >= _rate_limit_max:
+        return False
+    _rate_limit_timestamps.append(now)
+    return True
+
+
+async def submit_report(
+    description: str,
+    reporter_email: str | None,
+    screenshot_base64: str | None,
+    support_info: dict | None,
+) -> dict:
+    """Submit a bug report via the bambuddy.cool relay."""
+    if not _check_rate_limit():
+        return {
+            "success": False,
+            "message": "Rate limit exceeded. Please try again later.",
+            "issue_url": None,
+            "issue_number": None,
+        }
+
+    if not BUG_REPORT_RELAY_URL:
+        return {
+            "success": False,
+            "message": "Bug reporting is not configured. BUG_REPORT_RELAY_URL is not set.",
+            "issue_url": None,
+            "issue_number": None,
+        }
+
+    # Build relay payload — email is sent to relay for maintainer notification + issue body
+    payload: dict = {"description": description}
+    if reporter_email:
+        payload["reporter_email"] = reporter_email
+    if screenshot_base64:
+        payload["screenshot_base64"] = screenshot_base64
+    if support_info:
+        payload["support_info"] = support_info
+
+    try:
+        async with httpx.AsyncClient(timeout=60.0) as client:
+            resp = await client.post(BUG_REPORT_RELAY_URL, json=payload)
+            if resp.status_code != 200:
+                error_msg = f"Relay returned HTTP {resp.status_code}"
+                logger.error("%s at %s", error_msg, BUG_REPORT_RELAY_URL)
+                async with async_session() as db:
+                    report = BugReport(
+                        description=description,
+                        reporter_email=reporter_email,
+                        status="failed",
+                        error_message=error_msg,
+                    )
+                    db.add(report)
+                    await db.commit()
+                return {
+                    "success": False,
+                    "message": "Bug report relay is not available. Please try again later.",
+                    "issue_url": None,
+                    "issue_number": None,
+                }
+            relay_data = resp.json()
+    except Exception:
+        logger.exception("Failed to reach bug report relay at %s", BUG_REPORT_RELAY_URL)
+        async with async_session() as db:
+            report = BugReport(
+                description=description,
+                reporter_email=reporter_email,
+                status="failed",
+                error_message="Failed to reach bug report relay",
+            )
+            db.add(report)
+            await db.commit()
+
+        return {
+            "success": False,
+            "message": "Failed to submit bug report. Please try again later.",
+            "issue_url": None,
+            "issue_number": None,
+        }
+
+    if not relay_data.get("success"):
+        async with async_session() as db:
+            report = BugReport(
+                description=description,
+                reporter_email=reporter_email,
+                status="failed",
+                error_message=relay_data.get("message", "Relay returned failure"),
+            )
+            db.add(report)
+            await db.commit()
+
+        return {
+            "success": False,
+            "message": relay_data.get("message", "Failed to create bug report."),
+            "issue_url": None,
+            "issue_number": None,
+        }
+
+    issue_number = relay_data["issue_number"]
+    issue_url = relay_data["issue_url"]
+
+    # Save to DB
+    async with async_session() as db:
+        report = BugReport(
+            description=description,
+            reporter_email=reporter_email,
+            github_issue_number=issue_number,
+            github_issue_url=issue_url,
+            status="submitted",
+            email_sent=True,
+        )
+        db.add(report)
+        await db.commit()
+
+    return {
+        "success": True,
+        "message": "Bug report submitted successfully!",
+        "issue_url": issue_url,
+        "issue_number": issue_number,
+    }

+ 336 - 0
backend/tests/unit/test_bug_report.py

@@ -0,0 +1,336 @@
+"""Unit tests for bug report service and route."""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+
+class TestBugReportService:
+    """Tests for bug_report.submit_report()."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_submit_success(self):
+        """Successful relay call saves report and returns issue details."""
+        from backend.app.services.bug_report import submit_report
+
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+        mock_response.json.return_value = {
+            "success": True,
+            "message": "Created",
+            "issue_url": "https://github.com/maziggy/bambuddy/issues/99",
+            "issue_number": 99,
+        }
+
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock()
+        mock_db.commit = AsyncMock()
+
+        with (
+            patch("backend.app.services.bug_report.httpx.AsyncClient") as mock_client_cls,
+            patch("backend.app.services.bug_report.async_session") as mock_session,
+            patch("backend.app.services.bug_report._rate_limit_timestamps", []),
+            patch("backend.app.services.bug_report.BUG_REPORT_RELAY_URL", "https://example.com/api/bug-report"),
+        ):
+            mock_client = AsyncMock()
+            mock_client.post = AsyncMock(return_value=mock_response)
+            mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+            mock_client.__aexit__ = AsyncMock(return_value=False)
+            mock_client_cls.return_value = mock_client
+
+            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            result = await submit_report(
+                description="Test bug",
+                reporter_email="user@test.com",
+                screenshot_base64=None,
+                support_info=None,
+            )
+
+        assert result["success"] is True
+        assert result["issue_number"] == 99
+        assert result["issue_url"] == "https://github.com/maziggy/bambuddy/issues/99"
+        mock_db.add.assert_called_once()
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_submit_rate_limited(self):
+        """Returns failure when rate limit exceeded."""
+        import time
+
+        from backend.app.services.bug_report import submit_report
+
+        timestamps = [time.time()] * 5  # Already at limit
+
+        with patch("backend.app.services.bug_report._rate_limit_timestamps", timestamps):
+            result = await submit_report(
+                description="Test",
+                reporter_email=None,
+                screenshot_base64=None,
+                support_info=None,
+            )
+
+        assert result["success"] is False
+        assert "Rate limit" in result["message"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_submit_no_relay_url(self):
+        """Returns failure when relay URL is not configured."""
+        from backend.app.services.bug_report import submit_report
+
+        with (
+            patch("backend.app.services.bug_report._rate_limit_timestamps", []),
+            patch("backend.app.services.bug_report.BUG_REPORT_RELAY_URL", ""),
+        ):
+            result = await submit_report(
+                description="Test",
+                reporter_email=None,
+                screenshot_base64=None,
+                support_info=None,
+            )
+
+        assert result["success"] is False
+        assert "not configured" in result["message"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_submit_relay_http_error(self):
+        """Non-200 relay response saves failed report."""
+        from backend.app.services.bug_report import submit_report
+
+        mock_response = MagicMock()
+        mock_response.status_code = 500
+
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock()
+        mock_db.commit = AsyncMock()
+
+        with (
+            patch("backend.app.services.bug_report.httpx.AsyncClient") as mock_client_cls,
+            patch("backend.app.services.bug_report.async_session") as mock_session,
+            patch("backend.app.services.bug_report._rate_limit_timestamps", []),
+            patch("backend.app.services.bug_report.BUG_REPORT_RELAY_URL", "https://example.com/api/bug-report"),
+        ):
+            mock_client = AsyncMock()
+            mock_client.post = AsyncMock(return_value=mock_response)
+            mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+            mock_client.__aexit__ = AsyncMock(return_value=False)
+            mock_client_cls.return_value = mock_client
+
+            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            result = await submit_report(
+                description="Test",
+                reporter_email=None,
+                screenshot_base64=None,
+                support_info=None,
+            )
+
+        assert result["success"] is False
+        assert "not available" in result["message"]
+        mock_db.add.assert_called_once()
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_submit_relay_connection_error(self):
+        """Connection failure saves failed report."""
+        from backend.app.services.bug_report import submit_report
+
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock()
+        mock_db.commit = AsyncMock()
+
+        with (
+            patch("backend.app.services.bug_report.httpx.AsyncClient") as mock_client_cls,
+            patch("backend.app.services.bug_report.async_session") as mock_session,
+            patch("backend.app.services.bug_report._rate_limit_timestamps", []),
+            patch("backend.app.services.bug_report.BUG_REPORT_RELAY_URL", "https://example.com/api/bug-report"),
+        ):
+            mock_client = AsyncMock()
+            mock_client.post = AsyncMock(side_effect=ConnectionError("Connection refused"))
+            mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+            mock_client.__aexit__ = AsyncMock(return_value=False)
+            mock_client_cls.return_value = mock_client
+
+            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            result = await submit_report(
+                description="Test",
+                reporter_email=None,
+                screenshot_base64=None,
+                support_info=None,
+            )
+
+        assert result["success"] is False
+        assert "Failed to submit" in result["message"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_submit_relay_failure_response(self):
+        """Relay returns success=false in JSON body."""
+        from backend.app.services.bug_report import submit_report
+
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+        mock_response.json.return_value = {
+            "success": False,
+            "message": "GitHub API error",
+        }
+
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock()
+        mock_db.commit = AsyncMock()
+
+        with (
+            patch("backend.app.services.bug_report.httpx.AsyncClient") as mock_client_cls,
+            patch("backend.app.services.bug_report.async_session") as mock_session,
+            patch("backend.app.services.bug_report._rate_limit_timestamps", []),
+            patch("backend.app.services.bug_report.BUG_REPORT_RELAY_URL", "https://example.com/api/bug-report"),
+        ):
+            mock_client = AsyncMock()
+            mock_client.post = AsyncMock(return_value=mock_response)
+            mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+            mock_client.__aexit__ = AsyncMock(return_value=False)
+            mock_client_cls.return_value = mock_client
+
+            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            result = await submit_report(
+                description="Test",
+                reporter_email=None,
+                screenshot_base64=None,
+                support_info=None,
+            )
+
+        assert result["success"] is False
+        assert "GitHub API error" in result["message"]
+
+
+class TestCollectDebugLogs:
+    """Tests for _collect_debug_logs()."""
+
+    @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
+
+        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._get_debug_setting", return_value=(False, None)),
+            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.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_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            result = await _collect_debug_logs()
+
+        assert result == "DEBUG log line"
+        assert apply_calls == [True, False]  # enabled then restored
+        assert mock_set.call_count == 2
+
+    @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
+
+        with (
+            patch("backend.app.api.routes.bug_report.async_session") as mock_session,
+            patch("backend.app.api.routes.bug_report._get_debug_setting", return_value=(True, None)),
+            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()
+
+        assert result == "logs"
+        mock_apply.assert_not_called()
+        mock_set.assert_not_called()
+
+    @pytest.mark.asyncio
+    @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
+
+        with (
+            patch("backend.app.api.routes.bug_report.async_session") as mock_session,
+            patch("backend.app.api.routes.bug_report._get_debug_setting", return_value=(True, None)),
+            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()
+
+        assert mock_pm.request_status_update.call_count == 2
+
+
+class TestRateLimit:
+    """Tests for rate limiting in bug report service."""
+
+    def test_check_rate_limit_allows_first(self):
+        """First request within window is allowed."""
+        from backend.app.services.bug_report import _check_rate_limit
+
+        with patch("backend.app.services.bug_report._rate_limit_timestamps", []):
+            assert _check_rate_limit() is True
+
+    def test_check_rate_limit_blocks_at_max(self):
+        """Requests at max limit are blocked."""
+        import time
+
+        from backend.app.services.bug_report import _check_rate_limit
+
+        now = time.time()
+        timestamps = [now] * 5
+
+        with patch("backend.app.services.bug_report._rate_limit_timestamps", timestamps):
+            assert _check_rate_limit() is False
+
+    def test_check_rate_limit_clears_old(self):
+        """Old timestamps outside window are cleared."""
+        import time
+
+        from backend.app.services.bug_report import _check_rate_limit
+
+        old_time = time.time() - 7200  # 2 hours ago
+        timestamps = [old_time] * 5
+
+        with patch("backend.app.services.bug_report._rate_limit_timestamps", timestamps):
+            assert _check_rate_limit() is True

+ 177 - 0
frontend/src/__tests__/components/BugReportBubble.test.tsx

@@ -0,0 +1,177 @@
+/**
+ * Tests for the BugReportBubble component.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { render, screen, waitFor } from '../utils';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import { BugReportBubble } from '../../components/BugReportBubble';
+
+function getDescriptionTextarea() {
+  return document.querySelector('textarea') as HTMLTextAreaElement;
+}
+
+function getSubmitButton() {
+  const buttons = screen.getAllByRole('button');
+  return buttons.find(
+    (b) =>
+      b.className.includes('bg-red-500') &&
+      !b.className.includes('rounded-full') &&
+      b.textContent !== ''
+  );
+}
+
+describe('BugReportBubble', () => {
+  it('renders the floating bug button', () => {
+    render(<BugReportBubble />);
+
+    const button = screen.getByRole('button');
+    expect(button).toBeInTheDocument();
+  });
+
+  it('opens panel when bubble is clicked', async () => {
+    const user = userEvent.setup();
+
+    render(<BugReportBubble />);
+    await user.click(screen.getByRole('button'));
+
+    expect(getDescriptionTextarea()).toBeInTheDocument();
+  });
+
+  it('closes panel when X button is clicked', async () => {
+    const user = userEvent.setup();
+
+    render(<BugReportBubble />);
+
+    // Open
+    await user.click(screen.getByRole('button'));
+    expect(getDescriptionTextarea()).toBeInTheDocument();
+
+    // Close via the X button
+    const buttons = screen.getAllByRole('button');
+    const closeButton = buttons.find((b) => b.querySelector('.lucide-x'));
+    if (closeButton) await user.click(closeButton);
+
+    await waitFor(() => {
+      expect(document.querySelector('textarea')).not.toBeInTheDocument();
+    });
+  });
+
+  it('disables submit when description is empty', async () => {
+    const user = userEvent.setup();
+
+    render(<BugReportBubble />);
+    await user.click(screen.getByRole('button'));
+
+    expect(getSubmitButton()).toBeDisabled();
+  });
+
+  it('enables submit when description is provided', async () => {
+    const user = userEvent.setup();
+
+    render(<BugReportBubble />);
+    await user.click(screen.getByRole('button'));
+
+    await user.type(getDescriptionTextarea(), 'Something is broken');
+
+    expect(getSubmitButton()).not.toBeDisabled();
+  });
+
+  it('shows collecting state with countdown after submit', 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 });
+      })
+    );
+
+    render(<BugReportBubble />);
+    await user.click(screen.getByRole('button'));
+
+    await user.type(getDescriptionTextarea(), 'Test bug report');
+
+    const submitBtn = getSubmitButton();
+    if (submitBtn) await user.click(submitBtn);
+
+    // Should show collecting state
+    await waitFor(() => {
+      const collectingText = screen.queryByText(/collecting|Collecting|収集|Sammeln|Collecte|Raccolta|Coletando|收集/i);
+      expect(collectingText).toBeInTheDocument();
+    });
+  });
+
+  it('shows success state after successful submission', async () => {
+    const user = userEvent.setup();
+
+    server.use(
+      http.post('*/bug-report/submit', () => {
+        return HttpResponse.json({
+          success: true,
+          message: 'Bug report submitted successfully!',
+          issue_url: 'https://github.com/maziggy/bambuddy/issues/42',
+          issue_number: 42,
+        });
+      })
+    );
+
+    render(<BugReportBubble />);
+    await user.click(screen.getByRole('button'));
+
+    await user.type(getDescriptionTextarea(), 'Test bug');
+
+    const submitBtn = getSubmitButton();
+    if (submitBtn) await user.click(submitBtn);
+
+    await waitFor(
+      () => {
+        expect(screen.getByText(/#42/)).toBeInTheDocument();
+      },
+      { timeout: 35000 }
+    );
+  });
+
+  it('shows error state after failed submission', async () => {
+    const user = userEvent.setup();
+
+    server.use(
+      http.post('*/bug-report/submit', () => {
+        return HttpResponse.json({
+          success: false,
+          message: 'Relay not available',
+          issue_url: null,
+          issue_number: null,
+        });
+      })
+    );
+
+    render(<BugReportBubble />);
+    await user.click(screen.getByRole('button'));
+
+    await user.type(getDescriptionTextarea(), 'Test bug');
+
+    const submitBtn = getSubmitButton();
+    if (submitBtn) await user.click(submitBtn);
+
+    await waitFor(
+      () => {
+        expect(screen.getByText(/Relay not available/)).toBeInTheDocument();
+      },
+      { timeout: 35000 }
+    );
+  });
+
+  it('has expandable data collection notice', async () => {
+    const user = userEvent.setup();
+
+    render(<BugReportBubble />);
+    await user.click(screen.getByRole('button'));
+
+    const details = document.querySelector('details');
+    expect(details).toBeInTheDocument();
+  });
+});

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

@@ -4936,3 +4936,25 @@ export const spoolbuddyApi = {
       body: '{}',
     }),
 };
+
+export interface BugReportRequest {
+  description: string;
+  email?: string;
+  screenshot_base64?: string;
+  include_support_info?: boolean;
+}
+
+export interface BugReportResponse {
+  success: boolean;
+  message: string;
+  issue_url?: string;
+  issue_number?: number;
+}
+
+export const bugReportApi = {
+  submit: (data: BugReportRequest) =>
+    request<BugReportResponse>('/bug-report/submit', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+};

+ 362 - 0
frontend/src/components/BugReportBubble.tsx

@@ -0,0 +1,362 @@
+import { useState, useRef, useCallback, useEffect } from 'react';
+import { Bug, X, Loader2, CheckCircle, AlertCircle, Trash2, Upload } 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;
+
+const MAX_DIMENSION = 1920;
+const JPEG_QUALITY = 0.7;
+
+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);
+  });
+}
+
+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 [countdown, setCountdown] = useState(0);
+  const modalRef = useRef<HTMLDivElement>(null);
+  const fileInputRef = useRef<HTMLInputElement>(null);
+
+  // Countdown timer for log collection phase
+  useEffect(() => {
+    if (viewState !== 'collecting') return;
+    if (countdown <= 0) {
+      setViewState('submitting');
+      return;
+    }
+    const timer = setTimeout(() => setCountdown((c) => c - 1), 1000);
+    return () => clearTimeout(timer);
+  }, [viewState, countdown]);
+
+  const handleOpen = () => {
+    setIsOpen(true);
+    setViewState('form');
+    setDescription('');
+    setEmail('');
+    setScreenshot(null);
+    setIssueUrl(null);
+    setIssueNumber(null);
+    setErrorMessage('');
+  };
+
+  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 handleSubmit = async () => {
+    if (!description.trim()) return;
+    setCountdown(LOG_COLLECTION_SECONDS);
+    setViewState('collecting');
+    try {
+      const result = await bugReportApi.submit({
+        description: description.trim(),
+        email: email.trim() || undefined,
+        screenshot_base64: screenshot || undefined,
+        include_support_info: true,
+      });
+      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' && (
+                <>
+                  {/* 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={handleSubmit}
+                      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')}
+                    </button>
+                  </div>
+                </>
+              )}
+
+              {(viewState === 'collecting' || 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>
+                  )}
+                </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>
+      )}
+    </>
+  );
+}

+ 2 - 0
frontend/src/components/Layout.tsx

@@ -14,6 +14,7 @@ import { useToast } from '../contexts/ToastContext';
 import { Card, CardHeader, CardContent } from './Card';
 import { parseUTCDate } from '../utils/date';
 import { Button } from './Button';
+import { BugReportBubble } from './BugReportBubble';
 
 interface NavItem {
   id: string;
@@ -1074,6 +1075,7 @@ export function Layout() {
           </Card>
         </div>
       )}
+      <BugReportBubble />
     </div>
   );
 }

+ 27 - 0
frontend/src/i18n/locales/de.ts

@@ -3778,4 +3778,31 @@ export default {
       createFailed: 'Spule konnte nicht erstellt werden',
     },
   },
+
+  bugReport: {
+    title: 'Fehler melden',
+    description: 'Beschreibung',
+    descriptionPlaceholder: 'Was ist schiefgelaufen? Bitte beschreiben Sie das Problem...',
+    email: 'E-Mail (optional)',
+    emailPlaceholder: 'ihre@email.de',
+    emailPrivacy: 'Falls angegeben, wird Ihre E-Mail in einem eingeklappten Abschnitt des GitHub-Issues aufgeführt, damit der Betreuer sich melden kann.',
+    screenshot: 'Screenshot',
+    uploadOrPaste: 'Bild hochladen, einfügen oder ziehen',
+    dataCollectedSummary: 'Welche Daten werden im Bericht gesendet?',
+    dataIncluded: 'Enthalten:',
+    dataIncludedList: 'App-Version, Betriebssystem, Architektur, Python-Version, Datenbankstatistiken (nur Anzahl), Druckermodelle, Düsenanzahl, Firmware-Versionen, Verbindungsstatus, Integrationsstatus (Spoolman, MQTT, HA), nicht-sensible Einstellungen, Netzwerkschnittstellenanzahl, Docker-Details, Abhängigkeitsversionen.',
+    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.',
+    submitting: 'Fehlerbericht wird gesendet...',
+    submitSuccess: 'Fehlerbericht erfolgreich gesendet!',
+    submitFailed: 'Fehlerbericht konnte nicht gesendet werden',
+    thankYou: 'Vielen Dank!',
+    submitted: 'Ihr Fehlerbericht wurde eingereicht.',
+    viewIssue: 'Issue ansehen',
+    unexpectedError: 'Ein unerwarteter Fehler ist aufgetreten',
+    countdownSeconds: '{{seconds}}s',
+  },
 };

+ 27 - 0
frontend/src/i18n/locales/en.ts

@@ -3783,4 +3783,31 @@ export default {
       createFailed: 'Failed to create spool',
     },
   },
+
+  bugReport: {
+    title: 'Report a Bug',
+    description: 'Description',
+    descriptionPlaceholder: 'What went wrong? Please describe the issue...',
+    email: 'Email (optional)',
+    emailPlaceholder: 'your@email.com',
+    emailPrivacy: 'If provided, your email will be included in a collapsed section of the GitHub issue so the maintainer can follow up.',
+    screenshot: 'Screenshot',
+    uploadOrPaste: 'Upload, paste, or drag an image',
+    dataCollectedSummary: 'What data is included in the report?',
+    dataIncluded: 'Included:',
+    dataIncludedList: 'App version, OS, architecture, Python version, database stats (counts only), printer models, nozzle counts, firmware versions, connectivity status, integration status (Spoolman, MQTT, HA), non-sensitive settings, network interface count, Docker details, dependency versions.',
+    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.',
+    submitting: 'Submitting bug report...',
+    submitSuccess: 'Bug report submitted successfully!',
+    submitFailed: 'Failed to submit bug report',
+    thankYou: 'Thank you!',
+    submitted: 'Your bug report has been submitted.',
+    viewIssue: 'View Issue',
+    unexpectedError: 'An unexpected error occurred',
+    countdownSeconds: '{{seconds}}s',
+  },
 };

+ 27 - 0
frontend/src/i18n/locales/fr.ts

@@ -3727,4 +3727,31 @@ export default {
       createFailed: 'Impossible de créer la bobine',
     },
   },
+
+  bugReport: {
+    title: 'Signaler un bug',
+    description: 'Description',
+    descriptionPlaceholder: "Qu'est-ce qui n'a pas fonctionné ? Veuillez décrire le problème...",
+    email: 'E-mail (optionnel)',
+    emailPlaceholder: 'votre@email.fr',
+    emailPrivacy: "Si fourni, votre e-mail sera inclus dans une section repliée de l'issue GitHub pour que le mainteneur puisse vous contacter.",
+    screenshot: "Capture d'écran",
+    uploadOrPaste: 'Télécharger, coller ou glisser une image',
+    dataCollectedSummary: 'Quelles données sont incluses dans le rapport ?',
+    dataIncluded: 'Inclus :',
+    dataIncludedList: "Version de l'app, OS, architecture, version Python, statistiques de base de données (compteurs uniquement), modèles d'imprimantes, nombre de buses, versions firmware, état de connexion, état des intégrations (Spoolman, MQTT, HA), paramètres non sensibles, nombre d'interfaces réseau, détails Docker, versions des dépendances.",
+    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.',
+    submitting: 'Envoi du rapport de bug...',
+    submitSuccess: 'Rapport de bug envoyé avec succès !',
+    submitFailed: "Échec de l'envoi du rapport de bug",
+    thankYou: 'Merci !',
+    submitted: 'Votre rapport de bug a été soumis.',
+    viewIssue: "Voir l'issue",
+    unexpectedError: 'Une erreur inattendue est survenue',
+    countdownSeconds: '{{seconds}}s',
+  },
 };

+ 27 - 0
frontend/src/i18n/locales/it.ts

@@ -3116,4 +3116,31 @@ export default {
       createFailed: 'Impossibile creare la bobina',
     },
   },
+
+  bugReport: {
+    title: 'Segnala un bug',
+    description: 'Descrizione',
+    descriptionPlaceholder: "Cosa è andato storto? Descrivi il problema...",
+    email: 'Email (opzionale)',
+    emailPlaceholder: 'tua@email.it',
+    emailPrivacy: 'Se fornita, la tua email sarà inclusa in una sezione compressa dell\'issue GitHub per permettere al manutentore di contattarti.',
+    screenshot: 'Screenshot',
+    uploadOrPaste: 'Carica, incolla o trascina un\'immagine',
+    dataCollectedSummary: 'Quali dati sono inclusi nel report?',
+    dataIncluded: 'Inclusi:',
+    dataIncludedList: 'Versione app, OS, architettura, versione Python, statistiche database (solo conteggi), modelli stampante, numero ugelli, versioni firmware, stato connessione, stato integrazioni (Spoolman, MQTT, HA), impostazioni non sensibili, conteggio interfacce di rete, dettagli Docker, versioni dipendenze.',
+    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.',
+    submitting: 'Invio segnalazione bug...',
+    submitSuccess: 'Segnalazione bug inviata con successo!',
+    submitFailed: "Impossibile inviare la segnalazione bug",
+    thankYou: 'Grazie!',
+    submitted: 'La tua segnalazione bug è stata inviata.',
+    viewIssue: "Vedi issue",
+    unexpectedError: 'Si è verificato un errore imprevisto',
+    countdownSeconds: '{{seconds}}s',
+  },
 };

+ 27 - 0
frontend/src/i18n/locales/ja.ts

@@ -3612,4 +3612,31 @@ export default {
       createFailed: 'スプールの作成に失敗しました',
     },
   },
+
+  bugReport: {
+    title: 'バグを報告',
+    description: '説明',
+    descriptionPlaceholder: '何が問題でしたか?問題を説明してください...',
+    email: 'メールアドレス(任意)',
+    emailPlaceholder: 'your@email.com',
+    emailPrivacy: '入力された場合、メールアドレスはGitHub Issueの折りたたみセクションに含まれ、メンテナーがフォローアップできるようになります。',
+    screenshot: 'スクリーンショット',
+    uploadOrPaste: '画像をアップロード、貼り付け、またはドラッグ',
+    dataCollectedSummary: 'レポートに含まれるデータは?',
+    dataIncluded: '含まれるもの:',
+    dataIncludedList: 'アプリバージョン、OS、アーキテクチャ、Pythonバージョン、データベース統計(件数のみ)、プリンターモデル、ノズル数、ファームウェアバージョン、接続状態、統合状態(Spoolman、MQTT、HA)、非機密設定、ネットワークインターフェース数、Docker詳細、依存関係バージョン。',
+    dataNeverIncluded: '含まれないもの:',
+    dataNeverIncludedList: 'プリンター名、シリアル番号、アクセスコード、パスワード、IPアドレス、メールアドレス、APIキー、トークン、Webhook URL、ホスト名、ユーザー名。',
+    submit: '送信',
+    collectingLogs: '診断ログを収集中...',
+    collectingLogsHint: 'デバッグログを有効化し、プリンターから最新データを取得しています。',
+    submitting: 'バグレポートを送信中...',
+    submitSuccess: 'バグレポートが正常に送信されました!',
+    submitFailed: 'バグレポートの送信に失敗しました',
+    thankYou: 'ありがとうございます!',
+    submitted: 'バグレポートが送信されました。',
+    viewIssue: 'Issueを表示',
+    unexpectedError: '予期しないエラーが発生しました',
+    countdownSeconds: '{{seconds}}秒',
+  },
 };

+ 27 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3725,4 +3725,31 @@ export default {
       createFailed: 'Falha ao criar bobina',
     },
   },
+
+  bugReport: {
+    title: 'Reportar um bug',
+    description: 'Descrição',
+    descriptionPlaceholder: 'O que deu errado? Por favor, descreva o problema...',
+    email: 'Email (opcional)',
+    emailPlaceholder: 'seu@email.com.br',
+    emailPrivacy: 'Se fornecido, seu email será incluído em uma seção recolhida da issue no GitHub para que o mantenedor possa entrar em contato.',
+    screenshot: 'Captura de tela',
+    uploadOrPaste: 'Enviar, colar ou arrastar uma imagem',
+    dataCollectedSummary: 'Quais dados são incluídos no relatório?',
+    dataIncluded: 'Incluídos:',
+    dataIncludedList: 'Versão do app, SO, arquitetura, versão Python, estatísticas do banco de dados (apenas contagens), modelos de impressora, quantidade de bicos, versões de firmware, status de conexão, status de integrações (Spoolman, MQTT, HA), configurações não sensíveis, contagem de interfaces de rede, detalhes Docker, versões de dependências.',
+    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.',
+    submitting: 'Enviando relatório de bug...',
+    submitSuccess: 'Relatório de bug enviado com sucesso!',
+    submitFailed: 'Falha ao enviar relatório de bug',
+    thankYou: 'Obrigado!',
+    submitted: 'Seu relatório de bug foi enviado.',
+    viewIssue: 'Ver issue',
+    unexpectedError: 'Ocorreu um erro inesperado',
+    countdownSeconds: '{{seconds}}s',
+  },
 };

+ 27 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3659,4 +3659,31 @@ export default {
       createFailed: '创建耗材失败',
     },
   },
+
+  bugReport: {
+    title: '报告错误',
+    description: '描述',
+    descriptionPlaceholder: '出了什么问题?请描述问题...',
+    email: '邮箱(可选)',
+    emailPlaceholder: 'your@email.com',
+    emailPrivacy: '如果提供,您的邮箱将包含在GitHub Issue的折叠部分中,以便维护者后续跟进。',
+    screenshot: '截图',
+    uploadOrPaste: '上传、粘贴或拖拽图片',
+    dataCollectedSummary: '报告中包含哪些数据?',
+    dataIncluded: '包含:',
+    dataIncludedList: '应用版本、操作系统、架构、Python版本、数据库统计(仅计数)、打印机型号、喷嘴数量、固件版本、连接状态、集成状态(Spoolman、MQTT、HA)、非敏感设置、网络接口数量、Docker详情、依赖版本。',
+    dataNeverIncluded: '绝不包含:',
+    dataNeverIncludedList: '打印机名称、序列号、访问代码、密码、IP地址、邮箱地址、API密钥、令牌、Webhook URL、主机名或用户名。',
+    submit: '提交',
+    collectingLogs: '正在收集诊断日志...',
+    collectingLogsHint: '已启用调试日志,正在查询打印机获取最新数据。',
+    submitting: '正在提交错误报告...',
+    submitSuccess: '错误报告提交成功!',
+    submitFailed: '提交错误报告失败',
+    thankYou: '谢谢!',
+    submitted: '您的错误报告已提交。',
+    viewIssue: '查看Issue',
+    unexpectedError: '发生了意外错误',
+    countdownSeconds: '{{seconds}}秒',
+  },
 };

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/index-BTkKp3Ax.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/index-CD4IeU9s.css


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/index-D5I4wfky.css


+ 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-DW20lBXQ.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-D5I4wfky.css">
+    <script type="module" crossorigin src="/assets/index-BTkKp3Ax.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-CD4IeU9s.css">
   </head>
   <body>
     <div id="root"></div>

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor