mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 07:01:40 +02:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user