Browse Source

Add support bundle feature for issue reporting

  - Add /api/v1/support/debug-logging endpoints to toggle debug log level
  - Add /api/v1/support/bundle endpoint to generate ZIP with system info and logs
  - Debug logging state persists across restarts via Settings database
  - Add debug logging indicator banner in Layout with real-time duration timer
  - Add Support & Troubleshooting section to System Information page
  - Privacy protection:
    - Filter sensitive settings (emails, keys, tokens, URLs, configs)
    - Sanitize paths to remove usernames
    - Remove hostname from collected data
    - Replace IP addresses with [IP] and emails with [EMAIL] in logs
  - Add privacy info panel explaining what data is/isn't collected
  - Require debug logging to be enabled before downloading support bundle
maziggy 4 months ago
parent
commit
50fd0c018b

+ 6 - 0
CHANGELOG.md

@@ -5,6 +5,12 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.1.6b7] - 2026-01-04
 
 ### Added
+- **Support bundle for issue reporting** - Collect debug logs and system info for troubleshooting:
+  - Toggle debug logging from System Information page
+  - Debug logging indicator banner shows across all pages with live timer
+  - Download support bundle as ZIP file with sanitized logs and system info
+  - Privacy-focused: filters sensitive data (passwords, tokens, emails, IPs)
+  - Clear explanation of what data is/isn't collected
 - **Firmware update helper** - Check and upload firmware updates for LAN-only printers:
   - Automatic firmware update checking against Bambu Lab's servers
   - Orange "Update" badge on printer cards when updates are available

+ 3 - 1
README.md

@@ -108,12 +108,14 @@
 - SSDP discovery (appears in slicer automatically)
 - Secure TLS/MQTT communication
 
-### 🛠️ Maintenance
+### 🛠️ Maintenance & Support
 - Maintenance scheduling & tracking
 - Interval reminders (hours/days)
 - Print time accuracy stats
 - File manager for printer storage
 - Firmware update helper (LAN-only printers)
+- Debug logging toggle with live indicator
+- Support bundle generator (privacy-filtered)
 
 </td>
 </tr>

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

@@ -0,0 +1,347 @@
+"""Support endpoints for debug logging and support bundle generation."""
+
+import io
+import json
+import logging
+import os
+import platform
+import zipfile
+from datetime import datetime
+
+from fastapi import APIRouter, HTTPException
+from fastapi.responses import StreamingResponse
+from pydantic import BaseModel
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.config import APP_VERSION, settings
+from backend.app.core.database import async_session
+from backend.app.models.archive import PrintArchive
+from backend.app.models.filament import Filament
+from backend.app.models.printer import Printer
+from backend.app.models.project import Project
+from backend.app.models.settings import Settings
+from backend.app.models.smart_plug import SmartPlug
+
+router = APIRouter(prefix="/support", tags=["support"])
+logger = logging.getLogger(__name__)
+
+# In-memory state for debug logging (persisted to settings DB)
+_debug_logging_enabled = False
+_debug_logging_enabled_at: datetime | None = None
+
+
+class DebugLoggingState(BaseModel):
+    enabled: bool
+    enabled_at: str | None = None
+    duration_seconds: int | None = None
+
+
+class DebugLoggingToggle(BaseModel):
+    enabled: bool
+
+
+async def _get_debug_setting(db: AsyncSession) -> tuple[bool, datetime | None]:
+    """Get debug logging state from database."""
+    result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled"))
+    enabled_setting = result.scalar_one_or_none()
+
+    result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled_at"))
+    enabled_at_setting = result.scalar_one_or_none()
+
+    enabled = enabled_setting.value.lower() == "true" if enabled_setting else False
+    enabled_at = None
+    if enabled_at_setting and enabled_at_setting.value:
+        try:
+            enabled_at = datetime.fromisoformat(enabled_at_setting.value)
+        except ValueError:
+            pass
+
+    return enabled, enabled_at
+
+
+async def _set_debug_setting(db: AsyncSession, enabled: bool) -> datetime | None:
+    """Set debug logging state in database."""
+    # Update or create enabled setting
+    result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled"))
+    setting = result.scalar_one_or_none()
+    if setting:
+        setting.value = str(enabled).lower()
+    else:
+        db.add(Settings(key="debug_logging_enabled", value=str(enabled).lower()))
+
+    # Update enabled_at timestamp
+    enabled_at = datetime.now() if enabled else None
+    result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled_at"))
+    at_setting = result.scalar_one_or_none()
+    if at_setting:
+        at_setting.value = enabled_at.isoformat() if enabled_at else ""
+    else:
+        db.add(Settings(key="debug_logging_enabled_at", value=enabled_at.isoformat() if enabled_at else ""))
+
+    await db.commit()
+    return enabled_at
+
+
+def _apply_log_level(debug: bool):
+    """Apply log level change to root logger."""
+    root_logger = logging.getLogger()
+    new_level = logging.DEBUG if debug else logging.INFO
+
+    root_logger.setLevel(new_level)
+    for handler in root_logger.handlers:
+        handler.setLevel(new_level)
+
+    # Also adjust third-party loggers
+    if debug:
+        logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
+        logging.getLogger("httpcore").setLevel(logging.DEBUG)
+        logging.getLogger("httpx").setLevel(logging.DEBUG)
+    else:
+        logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
+        logging.getLogger("httpcore").setLevel(logging.WARNING)
+        logging.getLogger("httpx").setLevel(logging.WARNING)
+
+    logger.info(f"Log level changed to {'DEBUG' if debug else 'INFO'}")
+
+
+@router.get("/debug-logging", response_model=DebugLoggingState)
+async def get_debug_logging_state():
+    """Get current debug logging state."""
+    global _debug_logging_enabled, _debug_logging_enabled_at
+
+    async with async_session() as db:
+        enabled, enabled_at = await _get_debug_setting(db)
+        _debug_logging_enabled = enabled
+        _debug_logging_enabled_at = enabled_at
+
+    duration = None
+    if enabled and enabled_at:
+        duration = int((datetime.now() - enabled_at).total_seconds())
+
+    return DebugLoggingState(
+        enabled=enabled,
+        enabled_at=enabled_at.isoformat() if enabled_at else None,
+        duration_seconds=duration,
+    )
+
+
+@router.post("/debug-logging", response_model=DebugLoggingState)
+async def toggle_debug_logging(toggle: DebugLoggingToggle):
+    """Enable or disable debug logging."""
+    global _debug_logging_enabled, _debug_logging_enabled_at
+
+    async with async_session() as db:
+        enabled_at = await _set_debug_setting(db, toggle.enabled)
+        _debug_logging_enabled = toggle.enabled
+        _debug_logging_enabled_at = enabled_at
+
+    _apply_log_level(toggle.enabled)
+
+    duration = None
+    if toggle.enabled and enabled_at:
+        duration = int((datetime.now() - enabled_at).total_seconds())
+
+    return DebugLoggingState(
+        enabled=toggle.enabled,
+        enabled_at=enabled_at.isoformat() if enabled_at else None,
+        duration_seconds=duration,
+    )
+
+
+def _sanitize_path(path: str) -> str:
+    """Remove username from paths for privacy."""
+    import re
+
+    # Replace /home/username/ or /Users/username/ with /home/[user]/
+    path = re.sub(r"/home/[^/]+/", "/home/[user]/", path)
+    path = re.sub(r"/Users/[^/]+/", "/Users/[user]/", path)
+    # Replace /opt/username/ patterns
+    path = re.sub(r"/opt/[^/]+/", "/opt/[user]/", path)
+    return path
+
+
+async def _collect_support_info() -> dict:
+    """Collect all support information."""
+    info = {
+        "generated_at": datetime.now().isoformat(),
+        "app": {
+            "version": APP_VERSION,
+            "debug_mode": settings.debug,
+        },
+        "system": {
+            "platform": platform.system(),
+            "platform_release": platform.release(),
+            "platform_version": platform.version(),
+            "architecture": platform.machine(),
+            "python_version": platform.python_version(),
+        },
+        "environment": {
+            "docker": os.path.exists("/.dockerenv"),
+            "data_dir": _sanitize_path(str(settings.base_dir)),
+            "log_dir": _sanitize_path(str(settings.log_dir)),
+        },
+        "database": {},
+        "printers": [],
+        "settings": {},
+    }
+
+    async with async_session() as db:
+        # Database stats
+        result = await db.execute(select(func.count(PrintArchive.id)))
+        info["database"]["archives_total"] = result.scalar() or 0
+
+        result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed"))
+        info["database"]["archives_completed"] = result.scalar() or 0
+
+        result = await db.execute(select(func.count(Printer.id)))
+        info["database"]["printers_total"] = result.scalar() or 0
+
+        result = await db.execute(select(func.count(Filament.id)))
+        info["database"]["filaments_total"] = result.scalar() or 0
+
+        result = await db.execute(select(func.count(Project.id)))
+        info["database"]["projects_total"] = result.scalar() or 0
+
+        result = await db.execute(select(func.count(SmartPlug.id)))
+        info["database"]["smart_plugs_total"] = result.scalar() or 0
+
+        # Printer info (anonymized - just models and connection status)
+        result = await db.execute(select(Printer))
+        printers = result.scalars().all()
+        for i, printer in enumerate(printers):
+            info["printers"].append(
+                {
+                    "index": i + 1,
+                    "model": printer.model or "Unknown",
+                    "nozzle_count": printer.nozzle_count,
+                }
+            )
+
+        # Non-sensitive settings
+        result = await db.execute(select(Settings))
+        all_settings = result.scalars().all()
+        sensitive_keys = {
+            "access_code",
+            "password",
+            "token",
+            "secret",
+            "api_key",
+            "installation_id",
+            "cloud_token",
+            "mqtt_password",
+            "email",
+            "vapid",
+            "private_key",
+            "public_key",
+            "webhook",
+            "url",
+            "config",  # URLs may contain IPs, configs may have embedded secrets
+        }
+        for s in all_settings:
+            # Skip sensitive settings
+            if any(sensitive in s.key.lower() for sensitive in sensitive_keys):
+                continue
+            info["settings"][s.key] = s.value
+
+    return info
+
+
+def _sanitize_log_content(content: str) -> str:
+    """Remove sensitive data from log content."""
+    import re
+
+    # Replace IP addresses with [IP]
+    content = re.sub(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b", "[IP]", content)
+
+    # Replace email addresses
+    content = re.sub(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]", content)
+
+    # Replace paths with usernames
+    content = re.sub(r"/home/[^/\s]+/", "/home/[user]/", content)
+    content = re.sub(r"/Users/[^/\s]+/", "/Users/[user]/", content)
+    content = re.sub(r"/opt/[^/\s]+/", "/opt/[user]/", content)
+
+    return content
+
+
+def _get_log_content(max_bytes: int = 10 * 1024 * 1024) -> bytes:
+    """Get log file content, limited to max_bytes from the end."""
+    log_file = settings.log_dir / "bambuddy.log"
+    if not log_file.exists():
+        return b"Log file not found"
+
+    file_size = log_file.stat().st_size
+    if file_size <= max_bytes:
+        content = log_file.read_text(encoding="utf-8", errors="replace")
+    else:
+        # Read last max_bytes
+        with open(log_file, "rb") as f:
+            f.seek(file_size - max_bytes)
+            # Skip partial line at start
+            f.readline()
+            content = f.read().decode("utf-8", errors="replace")
+
+    # Sanitize sensitive data
+    content = _sanitize_log_content(content)
+    return content.encode("utf-8")
+
+
+@router.get("/bundle")
+async def generate_support_bundle():
+    """Generate a support bundle ZIP file for issue reporting."""
+    global _debug_logging_enabled, _debug_logging_enabled_at
+
+    # Check if debug logging is enabled
+    async with async_session() as db:
+        enabled, enabled_at = await _get_debug_setting(db)
+        _debug_logging_enabled = enabled
+        _debug_logging_enabled_at = enabled_at
+
+    if not enabled:
+        raise HTTPException(
+            status_code=400,
+            detail="Debug logging must be enabled before generating a support bundle. "
+            "Please enable debug logging, reproduce the issue, then generate the bundle.",
+        )
+
+    # Collect support info
+    support_info = await _collect_support_info()
+
+    # Create ZIP in memory
+    zip_buffer = io.BytesIO()
+    timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
+
+    with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+        # Add support info JSON
+        zf.writestr("support-info.json", json.dumps(support_info, indent=2, default=str))
+
+        # Add log file
+        log_content = _get_log_content()
+        zf.writestr("bambuddy.log", log_content)
+
+    zip_buffer.seek(0)
+
+    filename = f"bambuddy-support-{timestamp}.zip"
+    logger.info(f"Generated support bundle: {filename}")
+
+    return StreamingResponse(
+        zip_buffer, media_type="application/zip", headers={"Content-Disposition": f"attachment; filename={filename}"}
+    )
+
+
+async def init_debug_logging():
+    """Initialize debug logging state from database on startup."""
+    global _debug_logging_enabled, _debug_logging_enabled_at
+
+    try:
+        async with async_session() as db:
+            enabled, enabled_at = await _get_debug_setting(db)
+            _debug_logging_enabled = enabled
+            _debug_logging_enabled_at = enabled_at
+
+            if enabled:
+                _apply_log_level(True)
+                logger.info("Debug logging restored from previous session")
+    except Exception as e:
+        logger.warning(f"Could not restore debug logging state: {e}")

+ 6 - 0
backend/app/main.py

@@ -71,12 +71,14 @@ from backend.app.api.routes import (
     settings as settings_routes,
     smart_plugs,
     spoolman,
+    support,
     system,
     updates,
     webhook,
     websocket,
 )
 from backend.app.api.routes.maintenance import _get_printer_maintenance_internal, ensure_default_types
+from backend.app.api.routes.support import init_debug_logging
 from backend.app.core.database import async_session, init_db
 from backend.app.core.websocket import ws_manager
 from backend.app.models.smart_plug import SmartPlug
@@ -1689,6 +1691,9 @@ async def lifespan(app: FastAPI):
     # Startup
     await init_db()
 
+    # Restore debug logging state from previous session
+    await init_debug_logging()
+
     # Set up printer manager callbacks
     loop = asyncio.get_event_loop()
     printer_manager.set_event_loop(loop)
@@ -1810,6 +1815,7 @@ app.include_router(api_keys.router, prefix=app_settings.api_prefix)
 app.include_router(webhook.router, prefix=app_settings.api_prefix)
 app.include_router(ams_history.router, prefix=app_settings.api_prefix)
 app.include_router(system.router, prefix=app_settings.api_prefix)
+app.include_router(support.router, prefix=app_settings.api_prefix)
 app.include_router(websocket.router, prefix=app_settings.api_prefix)
 app.include_router(discovery.router, prefix=app_settings.api_prefix)
 app.include_router(pending_uploads.router, prefix=app_settings.api_prefix)

+ 5 - 0
frontend/src/__tests__/pages/SystemInfoPage.test.tsx

@@ -15,6 +15,11 @@ vi.mock('../../api/client', () => ({
     getSettings: vi.fn().mockResolvedValue({}),
     updateSettings: vi.fn().mockResolvedValue({}),
   },
+  supportApi: {
+    getDebugLoggingState: vi.fn().mockResolvedValue({ enabled: false, enabled_at: null, duration_seconds: null }),
+    setDebugLogging: vi.fn().mockResolvedValue({ enabled: true, enabled_at: new Date().toISOString(), duration_seconds: 0 }),
+    downloadSupportBundle: vi.fn().mockResolvedValue(undefined),
+  },
 }));
 
 // Mock system info response

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

@@ -2580,3 +2580,45 @@ export const firmwareApi = {
   getUploadStatus: (printerId: number) =>
     request<FirmwareUploadStatus>(`/firmware/updates/${printerId}/upload/status`),
 };
+
+// Support types
+export interface DebugLoggingState {
+  enabled: boolean;
+  enabled_at: string | null;
+  duration_seconds: number | null;
+}
+
+// Support API
+export const supportApi = {
+  getDebugLoggingState: () =>
+    request<DebugLoggingState>('/support/debug-logging'),
+
+  setDebugLogging: (enabled: boolean) =>
+    request<DebugLoggingState>('/support/debug-logging', {
+      method: 'POST',
+      body: JSON.stringify({ enabled }),
+    }),
+
+  downloadSupportBundle: async () => {
+    const response = await fetch(`${API_BASE}/support/bundle`);
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    // Get filename from Content-Disposition header or use default
+    const disposition = response.headers.get('Content-Disposition');
+    const filenameMatch = disposition?.match(/filename=(.+)/);
+    const filename = filenameMatch ? filenameMatch[1] : 'bambuddy-support.zip';
+
+    // Download the blob
+    const blob = await response.blob();
+    const url = window.URL.createObjectURL(blob);
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = filename;
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+    window.URL.revokeObjectURL(url);
+  },
+};

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

@@ -1,12 +1,12 @@
 import { useState, useEffect, useCallback, useRef } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
-import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, X, Menu, Info, Plug, type LucideIcon } from 'lucide-react';
+import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, X, Menu, Info, Plug, Bug, type LucideIcon } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
 import { SwitchbarPopover } from './SwitchbarPopover';
 import { useQuery } from '@tanstack/react-query';
-import { api } from '../api/client';
+import { api, supportApi } from '../api/client';
 import { getIconByName } from './IconPicker';
 import { useIsMobile } from '../hooks/useIsMobile';
 
@@ -118,6 +118,30 @@ export function Layout() {
 
   const hasSwitchbarPlugs = smartPlugs?.some(p => p.show_in_switchbar) ?? false;
 
+  // Check debug logging state
+  const { data: debugLoggingState } = useQuery({
+    queryKey: ['debugLogging'],
+    queryFn: supportApi.getDebugLoggingState,
+    staleTime: 60 * 1000, // 1 minute
+    refetchInterval: 60 * 1000, // Refresh every minute
+  });
+
+  // Calculate debug duration client-side for real-time updates
+  const [debugDuration, setDebugDuration] = useState<number | null>(null);
+  useEffect(() => {
+    if (!debugLoggingState?.enabled || !debugLoggingState.enabled_at) {
+      setDebugDuration(null);
+      return;
+    }
+    const enabledAt = new Date(debugLoggingState.enabled_at).getTime();
+    const updateDuration = () => {
+      setDebugDuration(Math.floor((Date.now() - enabledAt) / 1000));
+    };
+    updateDuration();
+    const interval = setInterval(updateDuration, 1000);
+    return () => clearInterval(interval);
+  }, [debugLoggingState?.enabled, debugLoggingState?.enabled_at]);
+
   // Build the unified sidebar items list
   const navItemsMap = new Map(defaultNavItems.map(item => [item.id, item]));
   const extLinksMap = new Map((externalLinks || []).map(link => [`ext-${link.id}`, link]));
@@ -592,6 +616,28 @@ export function Layout() {
       <main className={`flex-1 bg-bambu-dark overflow-auto transition-all duration-300 ${
         isMobile ? 'mt-14' : sidebarExpanded ? 'ml-64' : 'ml-16'
       }`}>
+        {/* Debug logging indicator */}
+        {debugLoggingState?.enabled && (
+          <div className="bg-amber-500/20 border-b border-amber-500/30 px-4 py-2 flex items-center justify-between">
+            <div className="flex items-center gap-2 text-sm">
+              <Bug className="w-4 h-4 text-amber-500 animate-pulse" />
+              <span className="text-amber-200">
+                {t('support.debugLoggingActive', { defaultValue: 'Debug logging is active' })}
+                {debugDuration !== null && (
+                  <span className="text-amber-300/70 ml-2">
+                    ({Math.floor(debugDuration / 60)}m {debugDuration % 60}s)
+                  </span>
+                )}
+              </span>
+              <button
+                onClick={() => navigate('/system')}
+                className="text-amber-400 hover:text-amber-300 font-medium underline ml-2"
+              >
+                {t('support.manageLogs', { defaultValue: 'Manage' })}
+              </button>
+            </div>
+          </div>
+        )}
         {/* Persistent update banner */}
         {showUpdateBanner && (
           <div className="bg-bambu-green/20 border-b border-bambu-green/30 px-4 py-2 flex items-center justify-between">

+ 166 - 2
frontend/src/pages/SystemInfoPage.tsx

@@ -1,4 +1,5 @@
-import { useQuery } from '@tanstack/react-query';
+import { useState } from 'react';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import {
   Server,
@@ -16,8 +17,11 @@ import {
   Plug,
   FolderKanban,
   Palette,
+  Bug,
+  Download,
+  Headphones,
 } from 'lucide-react';
-import { api } from '../api/client';
+import { api, supportApi } from '../api/client';
 import { Card } from '../components/Card';
 
 function StatCard({
@@ -80,6 +84,10 @@ function Section({
 
 export function SystemInfoPage() {
   const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const [bundleError, setBundleError] = useState<string | null>(null);
+  const [bundleDownloading, setBundleDownloading] = useState(false);
+  const [debugToggling, setDebugToggling] = useState(false);
 
   const { data: systemInfo, isLoading, refetch, isFetching } = useQuery({
     queryKey: ['systemInfo'],
@@ -87,6 +95,37 @@ export function SystemInfoPage() {
     refetchInterval: 30000, // Auto-refresh every 30 seconds
   });
 
+  const { data: debugLoggingState } = useQuery({
+    queryKey: ['debugLogging'],
+    queryFn: supportApi.getDebugLoggingState,
+    staleTime: 10 * 1000, // 10 seconds
+    refetchInterval: 10 * 1000,
+  });
+
+  const handleToggleDebugLogging = async () => {
+    setDebugToggling(true);
+    try {
+      await supportApi.setDebugLogging(!debugLoggingState?.enabled);
+      queryClient.invalidateQueries({ queryKey: ['debugLogging'] });
+    } catch (err) {
+      console.error('Failed to toggle debug logging:', err);
+    } finally {
+      setDebugToggling(false);
+    }
+  };
+
+  const handleDownloadBundle = async () => {
+    setBundleError(null);
+    setBundleDownloading(true);
+    try {
+      await supportApi.downloadSupportBundle();
+    } catch (err) {
+      setBundleError(err instanceof Error ? err.message : 'Failed to download support bundle');
+    } finally {
+      setBundleDownloading(false);
+    }
+  };
+
   if (isLoading) {
     return (
       <div className="flex items-center justify-center h-64">
@@ -158,6 +197,131 @@ export function SystemInfoPage() {
         </div>
       </Section>
 
+      {/* Support & Troubleshooting */}
+      <Section title={t('support.title', 'Support & Troubleshooting')} icon={Headphones}>
+        <div className="space-y-4">
+          <p className="text-sm text-bambu-gray">
+            {t('support.description', 'Enable debug logging to capture detailed information, then download a support bundle to share when reporting issues.')}
+          </p>
+
+          {/* Debug Logging Toggle */}
+          <div className="flex items-center justify-between p-4 bg-bambu-dark rounded-lg">
+            <div className="flex items-center gap-3">
+              <div className={`p-2 rounded-lg ${debugLoggingState?.enabled ? 'bg-amber-500/20 text-amber-500' : 'bg-bambu-dark-tertiary text-bambu-gray'}`}>
+                <Bug className="w-5 h-5" />
+              </div>
+              <div>
+                <p className="font-medium text-white">{t('support.debugLogging', 'Debug Logging')}</p>
+                <p className="text-sm text-bambu-gray">
+                  {debugLoggingState?.enabled
+                    ? t('support.debugLoggingEnabled', 'Capturing detailed logs')
+                    : t('support.debugLoggingDisabled', 'Normal logging level')}
+                  {debugLoggingState?.enabled && debugLoggingState.duration_seconds !== null && (
+                    <span className="text-amber-400 ml-2">
+                      ({Math.floor(debugLoggingState.duration_seconds / 60)}m {debugLoggingState.duration_seconds % 60}s)
+                    </span>
+                  )}
+                </p>
+              </div>
+            </div>
+            <button
+              onClick={handleToggleDebugLogging}
+              disabled={debugToggling}
+              className={`px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2 ${
+                debugLoggingState?.enabled
+                  ? 'bg-amber-500/20 text-amber-400 hover:bg-amber-500/30'
+                  : 'bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30'
+              } disabled:opacity-50`}
+            >
+              {debugToggling && <Loader2 className="w-4 h-4 animate-spin" />}
+              {debugLoggingState?.enabled
+                ? t('support.disableDebug', 'Disable')
+                : t('support.enableDebug', 'Enable')}
+            </button>
+          </div>
+
+          {/* Support Bundle Download */}
+          <div className="flex items-center justify-between p-4 bg-bambu-dark rounded-lg">
+            <div className="flex items-center gap-3">
+              <div className="p-2 rounded-lg bg-bambu-dark-tertiary text-bambu-green">
+                <Download className="w-5 h-5" />
+              </div>
+              <div>
+                <p className="font-medium text-white">{t('support.supportBundle', 'Support Bundle')}</p>
+                <p className="text-sm text-bambu-gray">
+                  {t('support.supportBundleDescription', 'Download system info and logs as a ZIP file')}
+                </p>
+              </div>
+            </div>
+            <button
+              onClick={handleDownloadBundle}
+              disabled={bundleDownloading || !debugLoggingState?.enabled}
+              className="px-4 py-2 rounded-lg font-medium bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
+              title={!debugLoggingState?.enabled ? t('support.enableDebugFirst', 'Enable debug logging first') : undefined}
+            >
+              {bundleDownloading && <Loader2 className="w-4 h-4 animate-spin" />}
+              {t('common.download', 'Download')}
+            </button>
+          </div>
+
+          {/* Error message */}
+          {bundleError && (
+            <div className="p-3 bg-red-500/20 border border-red-500/30 rounded-lg text-red-400 text-sm">
+              {bundleError}
+            </div>
+          )}
+
+          {/* Instructions */}
+          {!debugLoggingState?.enabled && (
+            <div className="p-4 bg-bambu-dark-tertiary/50 rounded-lg">
+              <p className="text-sm text-bambu-gray">
+                <span className="text-amber-400 font-medium">{t('support.instructions', 'To report an issue:')}</span>
+                <br />
+                1. {t('support.step1', 'Enable debug logging')}
+                <br />
+                2. {t('support.step2', 'Reproduce the issue')}
+                <br />
+                3. {t('support.step3', 'Download the support bundle')}
+                <br />
+                4. {t('support.step4', 'Attach the ZIP file to your issue report')}
+              </p>
+            </div>
+          )}
+
+          {/* Privacy Info */}
+          <div className="p-4 bg-bambu-dark rounded-lg space-y-3">
+            <p className="text-sm font-medium text-white">{t('support.privacyTitle', 'What\'s in the support bundle?')}</p>
+            <div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
+              <div>
+                <p className="text-bambu-green font-medium mb-1">{t('support.collected', 'Collected:')}</p>
+                <ul className="text-bambu-gray space-y-0.5">
+                  <li>• {t('support.collectItem1', 'App version and debug mode')}</li>
+                  <li>• {t('support.collectItem2', 'OS, architecture, Python version')}</li>
+                  <li>• {t('support.collectItem3', 'Database statistics (counts only)')}</li>
+                  <li>• {t('support.collectItem4', 'Printer models and nozzle counts')}</li>
+                  <li>• {t('support.collectItem5', 'Non-sensitive settings (themes, formats)')}</li>
+                  <li>• {t('support.collectItem6', 'Debug logs (sanitized)')}</li>
+                </ul>
+              </div>
+              <div>
+                <p className="text-red-400 font-medium mb-1">{t('support.notCollected', 'NOT collected:')}</p>
+                <ul className="text-bambu-gray space-y-0.5">
+                  <li>• {t('support.notItem1', 'Printer names, IPs, serial numbers')}</li>
+                  <li>• {t('support.notItem2', 'Access codes and passwords')}</li>
+                  <li>• {t('support.notItem3', 'Email addresses')}</li>
+                  <li>• {t('support.notItem4', 'API keys and tokens')}</li>
+                  <li>• {t('support.notItem5', 'Webhook URLs')}</li>
+                  <li>• {t('support.notItem6', 'Your hostname or username')}</li>
+                </ul>
+              </div>
+            </div>
+            <p className="text-xs text-bambu-gray/70">
+              {t('support.privacyNote', 'IP addresses in logs are replaced with [IP] and email addresses with [EMAIL].')}
+            </p>
+          </div>
+        </div>
+      </Section>
+
       {/* Database Stats */}
       <Section title={t('system.database', 'Database')} icon={Database}>
         <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BXGONv-e.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CrUIX1oi.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DO8dvHhf.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DgCUmTZF.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-DO8dvHhf.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DgCUmTZF.css">
+    <script type="module" crossorigin src="/assets/index-BXGONv-e.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-CrUIX1oi.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff