Browse Source

feat(system): log-health scanner + Add/Edit-Printer setup pre-flight

  Adds a passive log-health check that complements the active Connection
  Diagnostic. Scans Bambuddy's recent app log against a curated allowlist
  catalog of known failure signatures (rejected access code, FTPS :990
  timeout, FTPS TLS failure, flapping MQTT, unreachable camera, SQLite
  "database is locked" contention), dedupes and classifies each finding
  as layer8/environment/bug, and deep-links to the troubleshooting wiki.
  Sample log lines are sanitized before they leave the process. Exposed
  via GET /system/health and surfaced on two surfaces sharing one
  SystemHealthPanel component: a System Health section on the System
  page, and inline in the bug reporter when the form opens.

  The Add-Printer and Edit-Printer dialogs gained a setup-time pre-flight:
  saving runs the connection diagnostic and, on a failed check, warns with
  a "save anyway" escape hatch instead of silently saving a printer that
  will immediately show offline.

  Log read/parse/sanitize primitives extracted from routes/support.py into
  a shared services/log_reader.py (behaviour-preserving); affected support
  tests repointed accordingly.

  Tests: test_log_health.py (11), test_system_api.py (2 new),
  SystemHealthPanel + BugReportBubble + AddPrinterPreflight +
  EditPrinterPreflight (8 frontend). All strings translated across the 9
  locales. Backend ruff clean, full unit suite green, frontend build +
  eslint clean, i18n parity green.
maziggy 6 ngày trước cách đây
mục cha
commit
e222a0ef
31 tập tin đã thay đổi với 1879 bổ sung245 xóa
  1. 0 0
      CHANGELOG.md
  2. 11 195
      backend/app/api/routes/support.py
  3. 16 0
      backend/app/api/routes/system.py
  4. 256 0
      backend/app/services/log_health.py
  5. 213 0
      backend/app/services/log_reader.py
  6. 10 10
      backend/tests/integration/test_support_api.py
  7. 45 0
      backend/tests/integration/test_system_api.py
  8. 169 0
      backend/tests/unit/services/test_log_health.py
  9. 11 11
      backend/tests/unit/test_support_helpers.py
  10. 93 0
      frontend/src/__tests__/components/AddPrinterPreflight.test.tsx
  11. 32 0
      frontend/src/__tests__/components/BugReportBubble.test.tsx
  12. 109 0
      frontend/src/__tests__/components/EditPrinterPreflight.test.tsx
  13. 74 0
      frontend/src/__tests__/components/SystemHealthPanel.test.tsx
  14. 30 0
      frontend/src/api/client.ts
  15. 32 0
      frontend/src/components/BugReportBubble.tsx
  16. 107 0
      frontend/src/components/SystemHealthPanel.tsx
  17. 56 0
      frontend/src/i18n/locales/de.ts
  18. 56 0
      frontend/src/i18n/locales/en.ts
  19. 56 0
      frontend/src/i18n/locales/es.ts
  20. 56 0
      frontend/src/i18n/locales/fr.ts
  21. 56 0
      frontend/src/i18n/locales/it.ts
  22. 56 0
      frontend/src/i18n/locales/ja.ts
  23. 56 0
      frontend/src/i18n/locales/pt-BR.ts
  24. 56 0
      frontend/src/i18n/locales/zh-CN.ts
  25. 56 0
      frontend/src/i18n/locales/zh-TW.ts
  26. 133 27
      frontend/src/pages/PrintersPage.tsx
  27. 32 0
      frontend/src/pages/SystemInfoPage.tsx
  28. 0 0
      static/assets/index-BL4kzp1A.js
  29. 0 0
      static/assets/index-BzucE4G0.css
  30. 0 0
      static/assets/index-DYdMf_Qm.css
  31. 2 2
      static/index.html

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
CHANGELOG.md


+ 11 - 195
backend/app/api/routes/support.py

@@ -33,6 +33,12 @@ from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.user import User
 from backend.app.services.discovery import is_running_in_docker
+from backend.app.services.log_reader import (
+    LogEntry,
+    collect_sensitive_strings,
+    read_log_entries,
+    sanitize_log_content,
+)
 from backend.app.services.network_utils import get_network_interfaces
 from backend.app.services.printer_manager import printer_manager
 
@@ -156,15 +162,6 @@ async def toggle_debug_logging(
     )
 
 
-class LogEntry(BaseModel):
-    """A single log entry."""
-
-    timestamp: str
-    level: str
-    logger_name: str
-    message: str
-
-
 class LogsResponse(BaseModel):
     """Response containing log entries."""
 
@@ -173,107 +170,6 @@ class LogsResponse(BaseModel):
     filtered_count: int
 
 
-# Log line regex pattern: "2024-01-15 10:30:45,123 INFO [module.name] Message here"
-LOG_LINE_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2},\d{3})\s+(\w+)\s+\[([^\]]+)\]\s+(.*)$")
-
-
-def _parse_log_line(line: str) -> LogEntry | None:
-    """Parse a single log line into a LogEntry."""
-    match = LOG_LINE_PATTERN.match(line.strip())
-    if match:
-        return LogEntry(
-            timestamp=match.group(1),
-            level=match.group(2),
-            logger_name=match.group(3),
-            message=match.group(4),
-        )
-    return None
-
-
-def _read_log_entries(
-    limit: int = 200,
-    level_filter: str | None = None,
-    search: str | None = None,
-) -> tuple[list[LogEntry], int]:
-    """Read and parse log entries from file with optional filtering."""
-    log_file = settings.log_dir / "bambuddy.log"
-    if not log_file.exists():
-        return [], 0
-
-    entries: list[LogEntry] = []
-    total_lines = 0
-
-    try:
-        with open(log_file, encoding="utf-8", errors="replace") as f:
-            # Read all lines and process
-            lines = f.readlines()
-            total_lines = len(lines)
-
-            # Parse lines in reverse order (newest first)
-            current_entry: LogEntry | None = None
-            multi_line_buffer: list[str] = []
-
-            for line in reversed(lines):
-                parsed = _parse_log_line(line)
-                if parsed:
-                    # Found a new log entry start
-                    if current_entry:
-                        # Apply filters and add previous entry (without multi_line_buffer - it belongs to new entry)
-                        should_include = True
-
-                        # Level filter
-                        if level_filter and current_entry.level.upper() != level_filter.upper():
-                            should_include = False
-
-                        # Search filter (case-insensitive)
-                        if search and should_include:
-                            search_lower = search.lower()
-                            if not (
-                                search_lower in current_entry.message.lower()
-                                or search_lower in current_entry.logger_name.lower()
-                            ):
-                                should_include = False
-
-                        if should_include:
-                            entries.append(current_entry)
-
-                            if len(entries) >= limit:
-                                break
-
-                    # Set new entry and attach any accumulated multi-line content to it
-                    # (in reverse order, continuation lines come before their parent entry)
-                    current_entry = parsed
-                    if multi_line_buffer:
-                        current_entry.message += "\n" + "\n".join(reversed(multi_line_buffer))
-                    multi_line_buffer = []
-                elif line.strip():
-                    # Continuation of multi-line log entry (will be attached to next parsed entry)
-                    multi_line_buffer.append(line.rstrip())
-
-            # Don't forget the last (oldest) entry
-            # Note: any remaining multi_line_buffer would be orphaned lines before the first entry
-            if current_entry and len(entries) < limit:
-                should_include = True
-                if level_filter and current_entry.level.upper() != level_filter.upper():
-                    should_include = False
-                if search and should_include:
-                    search_lower = search.lower()
-                    if not (
-                        search_lower in current_entry.message.lower()
-                        or search_lower in current_entry.logger_name.lower()
-                    ):
-                        should_include = False
-                if should_include:
-                    entries.append(current_entry)
-
-    except Exception as e:
-        logger.error("Error reading log file: %s", e)
-        return [], 0
-
-    # Entries are already in newest-first order
-    return entries, total_lines
-
-
 @router.get("/logs", response_model=LogsResponse)
 async def get_logs(
     limit: int = Query(200, ge=1, le=1000, description="Maximum number of entries to return"),
@@ -282,7 +178,7 @@ async def get_logs(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
 ):
     """Get recent application log entries with optional filtering."""
-    entries, total_lines = _read_log_entries(limit=limit, level_filter=level, search=search)
+    entries, total_lines = read_log_entries(limit=limit, level_filter=level, search=search)
 
     return LogsResponse(
         entries=entries,
@@ -1206,42 +1102,6 @@ async def _collect_support_info() -> dict:
     return info
 
 
-def _sanitize_log_content(content: str, sensitive_strings: dict[str, str] | None = None) -> str:
-    """Remove sensitive data from log content."""
-    # First, replace known sensitive values (database-aware exact matching)
-    # This catches printer names, usernames, and other arbitrary user-chosen strings
-    # that regex patterns cannot detect
-    if sensitive_strings:
-        # Sort by length descending to avoid partial matches (e.g. "My Printer 1" before "My Printer")
-        for value, label in sorted(sensitive_strings.items(), key=lambda x: len(x[0]), reverse=True):
-            if len(value) < 3:
-                continue  # Skip very short strings to prevent over-redaction
-            content = re.sub(re.escape(value), label, content)
-
-    # Replace credentials in URLs (e.g. http://user:pass@host, rtsps://bblp:code@host)
-    content = re.sub(r"((?:https?|rtsps?)://)[^/:@\s]+:[^/@\s]+@", r"\1[CREDENTIALS]@", 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 Bambu Lab printer serial numbers (format: 00M/01D/01S/01P/03W + alphanumeric, 12-16 chars total)
-    content = re.sub(r"\b0[0-3][A-Z0-9][A-Z0-9]{9,13}\b", "[SERIAL]", content, flags=re.IGNORECASE)
-
-    # Replace IPv4 addresses (skip firmware versions like 01.09.01.00 which have leading zeros)
-    content = re.sub(
-        r"\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)\b",
-        "[IP]",
-        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, sensitive_strings: dict[str, str] | None = None) -> bytes:
     """Get log file content, limited to max_bytes from the end."""
     log_file = settings.log_dir / "bambuddy.log"
@@ -1260,35 +1120,15 @@ def _get_log_content(max_bytes: int = 10 * 1024 * 1024, sensitive_strings: dict[
             content = f.read().decode("utf-8", errors="replace")
 
     # Sanitize sensitive data
-    content = _sanitize_log_content(content, sensitive_strings)
+    content = sanitize_log_content(content, sensitive_strings)
     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, Printer.access_code))
-        for name, serial, ip_address, access_code in result.all():
-            if name:
-                sensitive_strings[name] = "[PRINTER]"
-            if serial:
-                sensitive_strings[serial] = "[SERIAL]"
-            if ip_address:
-                sensitive_strings[ip_address] = "[IP]"
-            if access_code:
-                sensitive_strings[access_code] = "[ACCESS_CODE]"
-
-        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]"
+        sensitive_strings = await collect_sensitive_strings(db)
 
     log_file = settings.log_dir / "bambuddy.log"
     if not log_file.exists():
@@ -1299,7 +1139,7 @@ async def _get_recent_sanitized_logs(max_lines: int = 200) -> str:
         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)
+        return sanitize_log_content(recent, sensitive_strings)
     except Exception:
         logger.debug("Failed to read logs for bug report", exc_info=True)
         return ""
@@ -1322,31 +1162,7 @@ async def generate_support_bundle(
             )
 
         # Collect known sensitive values for log redaction
-        sensitive_strings: dict[str, str] = {}
-
-        # Printer names, serial numbers, IP addresses, and access codes
-        result = await db.execute(select(Printer.name, Printer.serial_number, Printer.ip_address, Printer.access_code))
-        for name, serial, ip_address, access_code in result.all():
-            if name:
-                sensitive_strings[name] = "[PRINTER]"
-            if serial:
-                sensitive_strings[serial] = "[SERIAL]"
-            if ip_address:
-                sensitive_strings[ip_address] = "[IP]"
-            if access_code:
-                sensitive_strings[access_code] = "[ACCESS_CODE]"
-
-        # Auth usernames
-        result = await db.execute(select(User.username))
-        for (username,) in result.all():
-            if username:
-                sensitive_strings[username] = "[USER]"
-
-        # Bambu Cloud email
-        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]"
+        sensitive_strings = await collect_sensitive_strings(db)
 
     # Collect support info
     support_info = await _collect_support_info()

+ 16 - 0
backend/app/api/routes/system.py

@@ -23,6 +23,8 @@ from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.user import User
+from backend.app.services.log_health import ScanResult, scan_logs
+from backend.app.services.log_reader import collect_sensitive_strings
 from backend.app.services.printer_manager import printer_manager
 
 router = APIRouter(prefix="/system", tags=["system"])
@@ -574,3 +576,17 @@ async def get_storage_usage(
     """Get storage usage breakdown for Bambuddy data directories."""
     max_age_seconds = max(0, min(max_age_seconds, 3600))
     return await _get_storage_usage_cached(refresh=refresh, max_age_seconds=max_age_seconds)
+
+
+@router.get("/health", response_model=ScanResult)
+async def get_system_health(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
+):
+    """Scan the recent application log against the known-issue catalog.
+
+    Powers the self-service triage surfaces (System page + bug reporter).
+    Sample lines are sanitized before they leave the process.
+    """
+    sensitive_strings = await collect_sensitive_strings(db)
+    return await asyncio.to_thread(scan_logs, sensitive_strings=sensitive_strings)

+ 256 - 0
backend/app/services/log_health.py

@@ -0,0 +1,256 @@
+"""Log-health scanner.
+
+Matches the recent Bambuddy app log against a curated catalog of known failure
+signatures, so users can self-diagnose setup ("layer 8") issues before filing a
+bug report.
+
+The catalog is a deliberate *allowlist*: only known-bad, actionable signatures
+are matched — a healthy install produces an empty finding list. Human-readable
+cause and fix text is intentionally NOT stored here; the frontend renders it
+from i18n keys ``systemHealth.signature.<id>.{name,cause,fix}`` so it stays
+translatable across all locales. This module only carries the machine-facing
+fields (pattern, severity, category, wiki anchor).
+"""
+
+import logging
+import re
+from dataclasses import dataclass
+
+from pydantic import BaseModel
+
+from backend.app.core.config import settings
+from backend.app.services.log_reader import LogEntry, read_log_entries, sanitize_log_content
+
+logger = logging.getLogger(__name__)
+
+# How many recent log entries to scan by default.
+DEFAULT_SCAN_LIMIT = 4000
+
+# Log levels ranked so a signature can require "at least WARNING" etc.
+_LEVEL_RANK = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "ERROR": 40, "CRITICAL": 50}
+
+# Findings are ordered layer8 first (the user can act on these), then
+# environment, then bug (please report). Within a group: errors before warnings.
+_CATEGORY_ORDER = {"layer8": 0, "environment": 1, "bug": 2}
+_SEVERITY_ORDER = {"error": 0, "warning": 1}
+
+# Cap the sample line length so a finding can never carry a huge folded traceback.
+_SAMPLE_MAX_LEN = 400
+
+
+@dataclass(frozen=True)
+class LogSignature:
+    """One curated known-issue signature.
+
+    ``patterns`` are matched (``re.search``, case-insensitive) against the log
+    entry message. A signature only becomes a reported finding once it has
+    matched ``min_count`` times within the scan window — this gates noisy,
+    individually-benign symptoms (e.g. an occasional MQTT reconnect after a
+    Wi-Fi blip) from being surfaced as a problem.
+    """
+
+    id: str
+    patterns: tuple[re.Pattern[str], ...]
+    severity: str  # "error" | "warning"
+    category: str  # "layer8" | "environment" | "bug"
+    wiki_anchor: str  # slug appended to the troubleshooting wiki page URL
+    min_level: str = "WARNING"
+    logger_prefix: str | None = None  # only match entries from this logger tree
+    min_count: int = 1
+
+
+def _compile(*patterns: str) -> tuple[re.Pattern[str], ...]:
+    return tuple(re.compile(p, re.IGNORECASE) for p in patterns)
+
+
+# --- The catalog -----------------------------------------------------------
+# Seeded from the ranked "layer 8" root causes found in the closed-issue triage
+# review. Each id MUST have matching i18n keys: systemHealth.signature.<id>.*
+SIGNATURES: tuple[LogSignature, ...] = (
+    LogSignature(
+        # Wrong/mistyped access code — FTPS login is rejected (530).
+        id="ftp-auth-rejected",
+        patterns=_compile(r"FTP connection permission error"),
+        severity="error",
+        category="layer8",
+        wiki_anchor="wrong-access-code",
+        logger_prefix="backend.app.services.bambu_ftp",
+    ),
+    LogSignature(
+        # FTPS :990 unreachable — port blocked by a firewall, or the printer is
+        # off / on a different subnet.
+        id="ftp-connection-timeout",
+        patterns=_compile(r"FTP connection timed out"),
+        severity="warning",
+        category="layer8",
+        wiki_anchor="ftps-port-990-blocked",
+        logger_prefix="backend.app.services.bambu_ftp",
+        min_count=3,
+    ),
+    LogSignature(
+        # TLS negotiation to the printer's FTPS server failed.
+        id="ftp-ssl-error",
+        patterns=_compile(r"FTP SSL error connecting"),
+        severity="warning",
+        category="layer8",
+        wiki_anchor="ftps-tls-failure",
+        logger_prefix="backend.app.services.bambu_ftp",
+        min_count=3,
+    ),
+    LogSignature(
+        # MQTT connection keeps dropping — typically MQTT :8883 partially
+        # blocked, LAN mode unstable, or a flaky network path to the printer.
+        id="mqtt-connection-flapping",
+        patterns=_compile(r"Forcing MQTT reconnect", r"Hard reset reconnect failed"),
+        severity="warning",
+        category="layer8",
+        wiki_anchor="mqtt-connection-unstable",
+        logger_prefix="backend.app.services.bambu_mqtt",
+        min_count=5,
+    ),
+    LogSignature(
+        # Camera stream unreachable — RTSPS :322 blocked, or the printer
+        # camera / LAN liveview is disabled.
+        id="camera-connection-refused",
+        patterns=_compile(
+            r"Chamber image: connection refused",
+            r"Chamber image: connection timeout",
+            r"Camera connection test failed",
+        ),
+        severity="warning",
+        category="layer8",
+        wiki_anchor="camera-rtsps-port-322",
+        logger_prefix="backend.app.services.camera",
+        min_count=3,
+    ),
+    LogSignature(
+        # SQLite write contention. Surfaces inside exception tracebacks; folded
+        # continuation lines are part of the entry message, so this still
+        # matches. The fix is switching to PostgreSQL under multi-printer load.
+        id="database-locked",
+        patterns=_compile(r"database is locked"),
+        severity="error",
+        category="environment",
+        wiki_anchor="database-is-locked",
+    ),
+)
+
+
+class LogFinding(BaseModel):
+    """An aggregated, sanitized match of one signature against the log."""
+
+    signature_id: str
+    severity: str
+    category: str
+    wiki_anchor: str
+    count: int
+    first_seen: str
+    last_seen: str
+    sample: str
+
+
+class ScanResult(BaseModel):
+    """Result of a log-health scan."""
+
+    findings: list[LogFinding]
+    scanned_entries: int
+    log_available: bool
+    summary: dict[str, int]
+
+
+def _level_ok(entry: LogEntry, min_level: str) -> bool:
+    return _LEVEL_RANK.get(entry.level.upper(), 0) >= _LEVEL_RANK.get(min_level, 30)
+
+
+def _matches(sig: LogSignature, entry: LogEntry) -> bool:
+    if not _level_ok(entry, sig.min_level):
+        return False
+    if sig.logger_prefix and not entry.logger_name.startswith(sig.logger_prefix):
+        return False
+    return any(p.search(entry.message) for p in sig.patterns)
+
+
+def _sample_line(message: str) -> str:
+    """Take the first line of a (possibly multi-line) entry, length-capped."""
+    first_line = message.splitlines()[0] if message else ""
+    if len(first_line) > _SAMPLE_MAX_LEN:
+        return first_line[:_SAMPLE_MAX_LEN] + "…"
+    return first_line
+
+
+def scan_logs(
+    limit: int = DEFAULT_SCAN_LIMIT,
+    sensitive_strings: dict[str, str] | None = None,
+) -> ScanResult:
+    """Scan the recent app log against the signature catalog.
+
+    ``sensitive_strings`` (from :func:`log_reader.collect_sensitive_strings`) is
+    applied to every sample line so printer names, serials, IPs, and access
+    codes never leave the process. Even when it is ``None`` the regex-based
+    redaction passes still run.
+    """
+    log_file = settings.log_dir / "bambuddy.log"
+    log_available = log_file.exists()
+
+    entries, _total = read_log_entries(limit=limit)
+
+    # entry_id -> accumulator. entries arrive newest-first.
+    agg: dict[str, dict] = {}
+    for entry in entries:
+        for sig in SIGNATURES:
+            if not _matches(sig, entry):
+                continue
+            acc = agg.get(sig.id)
+            if acc is None:
+                # First (== newest) occurrence encountered.
+                agg[sig.id] = {
+                    "count": 1,
+                    "sample": entry.message,
+                    "last_seen": entry.timestamp,
+                    "first_seen": entry.timestamp,
+                }
+            else:
+                acc["count"] += 1
+                # Iterating newest-first, so each later hit is older.
+                acc["first_seen"] = entry.timestamp
+
+    findings: list[LogFinding] = []
+    for sig in SIGNATURES:
+        acc = agg.get(sig.id)
+        if acc is None or acc["count"] < sig.min_count:
+            continue
+        sample = sanitize_log_content(_sample_line(acc["sample"]), sensitive_strings)
+        findings.append(
+            LogFinding(
+                signature_id=sig.id,
+                severity=sig.severity,
+                category=sig.category,
+                wiki_anchor=sig.wiki_anchor,
+                count=acc["count"],
+                first_seen=acc["first_seen"],
+                last_seen=acc["last_seen"],
+                sample=sample,
+            )
+        )
+
+    findings.sort(
+        key=lambda f: (
+            _CATEGORY_ORDER.get(f.category, 9),
+            _SEVERITY_ORDER.get(f.severity, 9),
+            -f.count,
+        )
+    )
+
+    summary = {
+        "total": len(findings),
+        "layer8": sum(1 for f in findings if f.category == "layer8"),
+        "environment": sum(1 for f in findings if f.category == "environment"),
+        "bug": sum(1 for f in findings if f.category == "bug"),
+    }
+
+    return ScanResult(
+        findings=findings,
+        scanned_entries=len(entries),
+        log_available=log_available,
+        summary=summary,
+    )

+ 213 - 0
backend/app/services/log_reader.py

@@ -0,0 +1,213 @@
+"""Shared primitives for reading, parsing, and sanitizing the Bambuddy app log.
+
+Extracted from ``routes/support.py`` so service-layer code (e.g. the log-health
+scanner in ``log_health.py``) can reuse log reading and redaction without
+importing from the API layer. ``support.py`` re-imports these helpers and keeps
+its own route handlers.
+"""
+
+import logging
+import re
+
+from pydantic import BaseModel
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.config import settings
+from backend.app.models.printer import Printer
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+
+logger = logging.getLogger(__name__)
+
+# Log line format: "2024-01-15 10:30:45,123 INFO [module.name] [trace_id] Message"
+# The trace_id is left as part of the message group — callers that need it can
+# parse it out; the log-health scanner does not.
+LOG_LINE_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2},\d{3})\s+(\w+)\s+\[([^\]]+)\]\s+(.*)$")
+
+
+class LogEntry(BaseModel):
+    """A single parsed log entry."""
+
+    timestamp: str
+    level: str
+    logger_name: str
+    message: str
+
+
+def parse_log_line(line: str) -> LogEntry | None:
+    """Parse a single log line into a LogEntry, or None if it is not a line start."""
+    match = LOG_LINE_PATTERN.match(line.strip())
+    if match:
+        return LogEntry(
+            timestamp=match.group(1),
+            level=match.group(2),
+            logger_name=match.group(3),
+            message=match.group(4),
+        )
+    return None
+
+
+def read_log_entries(
+    limit: int = 200,
+    level_filter: str | None = None,
+    search: str | None = None,
+) -> tuple[list[LogEntry], int]:
+    """Read and parse log entries from ``bambuddy.log``, newest first.
+
+    Continuation lines (tracebacks etc.) are folded into the message of the
+    entry they belong to. Returns ``(entries, total_lines_in_file)``.
+    """
+    log_file = settings.log_dir / "bambuddy.log"
+    if not log_file.exists():
+        return [], 0
+
+    entries: list[LogEntry] = []
+    total_lines = 0
+
+    try:
+        with open(log_file, encoding="utf-8", errors="replace") as f:
+            lines = f.readlines()
+            total_lines = len(lines)
+
+            # Parse lines in reverse order (newest first)
+            current_entry: LogEntry | None = None
+            multi_line_buffer: list[str] = []
+
+            for line in reversed(lines):
+                parsed = parse_log_line(line)
+                if parsed:
+                    # Found a new log entry start
+                    if current_entry:
+                        # Apply filters and add previous entry (without multi_line_buffer - it belongs to new entry)
+                        should_include = True
+
+                        # Level filter
+                        if level_filter and current_entry.level.upper() != level_filter.upper():
+                            should_include = False
+
+                        # Search filter (case-insensitive)
+                        if search and should_include:
+                            search_lower = search.lower()
+                            if not (
+                                search_lower in current_entry.message.lower()
+                                or search_lower in current_entry.logger_name.lower()
+                            ):
+                                should_include = False
+
+                        if should_include:
+                            entries.append(current_entry)
+
+                            if len(entries) >= limit:
+                                break
+
+                    # Set new entry and attach any accumulated multi-line content to it
+                    # (in reverse order, continuation lines come before their parent entry)
+                    current_entry = parsed
+                    if multi_line_buffer:
+                        current_entry.message += "\n" + "\n".join(reversed(multi_line_buffer))
+                    multi_line_buffer = []
+                elif line.strip():
+                    # Continuation of multi-line log entry (will be attached to next parsed entry)
+                    multi_line_buffer.append(line.rstrip())
+
+            # Don't forget the last (oldest) entry
+            # Note: any remaining multi_line_buffer would be orphaned lines before the first entry
+            if current_entry and len(entries) < limit:
+                should_include = True
+                if level_filter and current_entry.level.upper() != level_filter.upper():
+                    should_include = False
+                if search and should_include:
+                    search_lower = search.lower()
+                    if not (
+                        search_lower in current_entry.message.lower()
+                        or search_lower in current_entry.logger_name.lower()
+                    ):
+                        should_include = False
+                if should_include:
+                    entries.append(current_entry)
+
+    except Exception as e:
+        logger.error("Error reading log file: %s", e)
+        return [], 0
+
+    # Entries are already in newest-first order
+    return entries, total_lines
+
+
+def sanitize_log_content(content: str, sensitive_strings: dict[str, str] | None = None) -> str:
+    """Remove sensitive data from log content.
+
+    ``sensitive_strings`` maps known exact values (printer names, serials, etc.)
+    to replacement labels; pass the result of :func:`collect_sensitive_strings`.
+    Regex passes additionally redact credentials in URLs, emails, serials, and
+    IP addresses that were not captured by exact matching.
+    """
+    # First, replace known sensitive values (database-aware exact matching)
+    # This catches printer names, usernames, and other arbitrary user-chosen strings
+    # that regex patterns cannot detect
+    if sensitive_strings:
+        # Sort by length descending to avoid partial matches (e.g. "My Printer 1" before "My Printer")
+        for value, label in sorted(sensitive_strings.items(), key=lambda x: len(x[0]), reverse=True):
+            if len(value) < 3:
+                continue  # Skip very short strings to prevent over-redaction
+            content = re.sub(re.escape(value), label, content)
+
+    # Replace credentials in URLs (e.g. http://user:pass@host, rtsps://bblp:code@host)
+    content = re.sub(r"((?:https?|rtsps?)://)[^/:@\s]+:[^/@\s]+@", r"\1[CREDENTIALS]@", 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 Bambu Lab printer serial numbers (format: 00M/01D/01S/01P/03W + alphanumeric, 12-16 chars total)
+    content = re.sub(r"\b0[0-3][A-Z0-9][A-Z0-9]{9,13}\b", "[SERIAL]", content, flags=re.IGNORECASE)
+
+    # Replace IPv4 addresses (skip firmware versions like 01.09.01.00 which have leading zeros)
+    content = re.sub(
+        r"\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)\b",
+        "[IP]",
+        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
+
+
+async def collect_sensitive_strings(db: AsyncSession) -> dict[str, str]:
+    """Collect known sensitive values from the database for log redaction.
+
+    Covers printer names, serial numbers, IP addresses, access codes, auth
+    usernames, and the Bambu Cloud email. Pass the result to
+    :func:`sanitize_log_content`.
+    """
+    sensitive_strings: dict[str, str] = {}
+
+    # Printer names, serial numbers, IP addresses, and access codes
+    result = await db.execute(select(Printer.name, Printer.serial_number, Printer.ip_address, Printer.access_code))
+    for name, serial, ip_address, access_code in result.all():
+        if name:
+            sensitive_strings[name] = "[PRINTER]"
+        if serial:
+            sensitive_strings[serial] = "[SERIAL]"
+        if ip_address:
+            sensitive_strings[ip_address] = "[IP]"
+        if access_code:
+            sensitive_strings[access_code] = "[ACCESS_CODE]"
+
+    # Auth usernames
+    result = await db.execute(select(User.username))
+    for (username,) in result.all():
+        if username:
+            sensitive_strings[username] = "[USER]"
+
+    # Bambu Cloud email
+    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]"
+
+    return sensitive_strings

+ 10 - 10
backend/tests/integration/test_support_api.py

@@ -22,7 +22,7 @@ class TestSupportLogsAPI:
     @pytest.mark.integration
     async def test_get_logs_empty_file(self, async_client: AsyncClient):
         """Verify get logs returns empty list when log file doesn't exist."""
-        with patch("backend.app.api.routes.support.settings") as mock_settings:
+        with patch("backend.app.services.log_reader.settings") as mock_settings:
             mock_settings.log_dir = Path("/nonexistent/path")
 
             response = await async_client.get("/api/v1/support/logs")
@@ -46,7 +46,7 @@ class TestSupportLogsAPI:
             log_file = Path(tmpdir) / "bambuddy.log"
             log_file.write_text(log_content)
 
-            with patch("backend.app.api.routes.support.settings") as mock_settings:
+            with patch("backend.app.services.log_reader.settings") as mock_settings:
                 mock_settings.log_dir = Path(tmpdir)
 
                 response = await async_client.get("/api/v1/support/logs")
@@ -76,7 +76,7 @@ class TestSupportLogsAPI:
             log_file = Path(tmpdir) / "bambuddy.log"
             log_file.write_text(log_content)
 
-            with patch("backend.app.api.routes.support.settings") as mock_settings:
+            with patch("backend.app.services.log_reader.settings") as mock_settings:
                 mock_settings.log_dir = Path(tmpdir)
 
                 response = await async_client.get("/api/v1/support/logs?level=ERROR")
@@ -100,7 +100,7 @@ class TestSupportLogsAPI:
             log_file = Path(tmpdir) / "bambuddy.log"
             log_file.write_text(log_content)
 
-            with patch("backend.app.api.routes.support.settings") as mock_settings:
+            with patch("backend.app.services.log_reader.settings") as mock_settings:
                 mock_settings.log_dir = Path(tmpdir)
 
                 response = await async_client.get("/api/v1/support/logs?search=printer")
@@ -124,7 +124,7 @@ class TestSupportLogsAPI:
             log_file = Path(tmpdir) / "bambuddy.log"
             log_file.write_text(log_content)
 
-            with patch("backend.app.api.routes.support.settings") as mock_settings:
+            with patch("backend.app.services.log_reader.settings") as mock_settings:
                 mock_settings.log_dir = Path(tmpdir)
 
                 response = await async_client.get("/api/v1/support/logs?limit=2")
@@ -153,7 +153,7 @@ ValueError: test error
             log_file = Path(tmpdir) / "bambuddy.log"
             log_file.write_text(log_content)
 
-            with patch("backend.app.api.routes.support.settings") as mock_settings:
+            with patch("backend.app.services.log_reader.settings") as mock_settings:
                 mock_settings.log_dir = Path(tmpdir)
 
                 response = await async_client.get("/api/v1/support/logs")
@@ -214,7 +214,7 @@ class TestLogParsingHelpers:
 
     def test_parse_log_line_valid(self):
         """Verify _parse_log_line handles valid log lines."""
-        from backend.app.api.routes.support import _parse_log_line
+        from backend.app.services.log_reader import parse_log_line as _parse_log_line
 
         line = "2024-01-15 10:30:45,123 INFO [backend.app.main] Server started"
         entry = _parse_log_line(line)
@@ -227,7 +227,7 @@ class TestLogParsingHelpers:
 
     def test_parse_log_line_invalid(self):
         """Verify _parse_log_line returns None for invalid lines."""
-        from backend.app.api.routes.support import _parse_log_line
+        from backend.app.services.log_reader import parse_log_line as _parse_log_line
 
         line = "This is not a valid log line"
         entry = _parse_log_line(line)
@@ -236,7 +236,7 @@ class TestLogParsingHelpers:
 
     def test_parse_log_line_with_brackets_in_message(self):
         """Verify _parse_log_line handles messages with brackets."""
-        from backend.app.api.routes.support import _parse_log_line
+        from backend.app.services.log_reader import parse_log_line as _parse_log_line
 
         line = "2024-01-15 10:30:45,123 INFO [backend.app.main] Processing [item 1] and [item 2]"
         entry = _parse_log_line(line)
@@ -246,7 +246,7 @@ class TestLogParsingHelpers:
 
     def test_parse_log_line_all_levels(self):
         """Verify _parse_log_line handles all log levels."""
-        from backend.app.api.routes.support import _parse_log_line
+        from backend.app.services.log_reader import parse_log_line as _parse_log_line
 
         levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
         for level in levels:

+ 45 - 0
backend/tests/integration/test_system_api.py

@@ -308,3 +308,48 @@ class TestSystemHelperFunctions:
 
         result = format_uptime(30)  # 30 seconds
         assert result == "< 1m"
+
+
+class TestSystemHealthAPI:
+    """Integration tests for GET /api/v1/system/health (log-health scan)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_health_clean_log(self, async_client: AsyncClient, tmp_path, monkeypatch):
+        """A log with no known issues returns an empty, healthy result."""
+        from backend.app.core.config import settings
+
+        (tmp_path / "bambuddy.log").write_text(
+            "2026-05-22 10:00:00,000 INFO [backend.app.main] Application startup complete\n",
+            encoding="utf-8",
+        )
+        monkeypatch.setattr(settings, "log_dir", tmp_path)
+
+        response = await async_client.get("/api/v1/system/health")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["log_available"] is True
+        assert result["findings"] == []
+        assert result["summary"]["total"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_health_detects_known_issue(self, async_client: AsyncClient, tmp_path, monkeypatch):
+        """A known signature in the log surfaces as a finding."""
+        from backend.app.core.config import settings
+
+        (tmp_path / "bambuddy.log").write_text(
+            "2026-05-22 10:00:00,000 WARNING [backend.app.services.bambu_ftp] "
+            "FTP connection permission error to 10.0.0.9: 530\n",
+            encoding="utf-8",
+        )
+        monkeypatch.setattr(settings, "log_dir", tmp_path)
+
+        response = await async_client.get("/api/v1/system/health")
+
+        assert response.status_code == 200
+        result = response.json()
+        ids = [f["signature_id"] for f in result["findings"]]
+        assert "ftp-auth-rejected" in ids
+        assert result["summary"]["layer8"] >= 1

+ 169 - 0
backend/tests/unit/services/test_log_health.py

@@ -0,0 +1,169 @@
+"""Tests for the log-health scanner (backend/app/services/log_health.py)."""
+
+import pytest
+
+from backend.app.core.config import settings
+from backend.app.services.log_health import SIGNATURES, scan_logs
+
+
+def _line(level, logger, msg, ts="2026-05-22 10:00:00,000"):
+    """Build one log line in the app's log format: TS LEVEL [logger] message."""
+    return f"{ts} {level} [{logger}] {msg}"
+
+
+def _write_log(tmp_path, monkeypatch, lines):
+    log_file = tmp_path / "bambuddy.log"
+    log_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
+    monkeypatch.setattr(settings, "log_dir", tmp_path)
+    return log_file
+
+
+FTP_LOGGER = "backend.app.services.bambu_ftp"
+MQTT_LOGGER = "backend.app.services.bambu_mqtt"
+CAM_LOGGER = "backend.app.services.camera"
+
+
+def test_clean_log_has_no_findings(tmp_path, monkeypatch):
+    _write_log(
+        tmp_path,
+        monkeypatch,
+        [
+            _line("INFO", "backend.app.main", "Application startup complete"),
+            _line("INFO", FTP_LOGGER, "FTP connected, logging in as bblp"),
+        ],
+    )
+    result = scan_logs()
+    assert result.findings == []
+    assert result.log_available is True
+    assert result.scanned_entries == 2
+    assert result.summary == {"total": 0, "layer8": 0, "environment": 0, "bug": 0}
+
+
+def test_log_unavailable_when_file_missing(tmp_path, monkeypatch):
+    # No log file written.
+    monkeypatch.setattr(settings, "log_dir", tmp_path)
+    result = scan_logs()
+    assert result.log_available is False
+    assert result.findings == []
+
+
+def test_ftp_auth_rejected_is_detected(tmp_path, monkeypatch):
+    _write_log(
+        tmp_path,
+        monkeypatch,
+        [_line("WARNING", FTP_LOGGER, "FTP connection permission error to 10.0.0.9: 530 Login incorrect")],
+    )
+    result = scan_logs()
+    assert len(result.findings) == 1
+    f = result.findings[0]
+    assert f.signature_id == "ftp-auth-rejected"
+    assert f.severity == "error"
+    assert f.category == "layer8"
+    assert f.count == 1
+
+
+def test_min_count_gates_low_frequency_signals(tmp_path, monkeypatch):
+    # ftp-connection-timeout requires min_count=3 — two hits must not surface.
+    _write_log(
+        tmp_path,
+        monkeypatch,
+        [_line("WARNING", FTP_LOGGER, "FTP connection timed out to 10.0.0.9: timed out")] * 2,
+    )
+    assert scan_logs().findings == []
+
+    _write_log(
+        tmp_path,
+        monkeypatch,
+        [_line("WARNING", FTP_LOGGER, "FTP connection timed out to 10.0.0.9: timed out")] * 3,
+    )
+    findings = scan_logs().findings
+    assert len(findings) == 1
+    assert findings[0].signature_id == "ftp-connection-timeout"
+    assert findings[0].count == 3
+
+
+def test_aggregation_tracks_count_and_seen_range(tmp_path, monkeypatch):
+    _write_log(
+        tmp_path,
+        monkeypatch,
+        [
+            _line("WARNING", FTP_LOGGER, "FTP connection permission error to 10.0.0.9", ts="2026-05-22 09:00:00,000"),
+            _line("WARNING", FTP_LOGGER, "FTP connection permission error to 10.0.0.9", ts="2026-05-22 10:30:00,000"),
+        ],
+    )
+    f = scan_logs().findings[0]
+    assert f.count == 2
+    assert f.first_seen == "2026-05-22 09:00:00,000"
+    assert f.last_seen == "2026-05-22 10:30:00,000"
+
+
+def test_logger_prefix_filters_unrelated_loggers(tmp_path, monkeypatch):
+    # Same text, but logged by an unrelated logger — must not match the
+    # bambu_ftp-scoped signature.
+    _write_log(
+        tmp_path,
+        monkeypatch,
+        [_line("WARNING", "backend.app.services.something_else", "FTP connection permission error to 10.0.0.9")],
+    )
+    assert scan_logs().findings == []
+
+
+def test_min_level_filters_below_threshold(tmp_path, monkeypatch):
+    # ftp-auth-rejected requires at least WARNING — an INFO line must not match.
+    _write_log(
+        tmp_path,
+        monkeypatch,
+        [_line("INFO", FTP_LOGGER, "FTP connection permission error to 10.0.0.9")],
+    )
+    assert scan_logs().findings == []
+
+
+def test_sample_is_sanitized(tmp_path, monkeypatch):
+    _write_log(
+        tmp_path,
+        monkeypatch,
+        [_line("WARNING", FTP_LOGGER, "FTP connection permission error to 192.168.1.50: 530")],
+    )
+    f = scan_logs().findings[0]
+    assert "192.168.1.50" not in f.sample
+    assert "[IP]" in f.sample
+
+
+def test_database_locked_matches_inside_traceback(tmp_path, monkeypatch):
+    # The signature text appears on a continuation line of a multi-line entry;
+    # read_log_entries folds it into the parent message.
+    _write_log(
+        tmp_path,
+        monkeypatch,
+        [
+            _line("ERROR", "backend.app.core.database", "Unhandled DB error"),
+            "Traceback (most recent call last):",
+            "sqlite3.OperationalError: database is locked",
+        ],
+    )
+    findings = scan_logs().findings
+    assert len(findings) == 1
+    assert findings[0].signature_id == "database-locked"
+    assert findings[0].category == "environment"
+
+
+def test_findings_sorted_layer8_then_environment(tmp_path, monkeypatch):
+    _write_log(
+        tmp_path,
+        monkeypatch,
+        [
+            _line("ERROR", "backend.app.core.database", "x: database is locked"),
+            _line("WARNING", FTP_LOGGER, "FTP connection timed out to 10.0.0.9"),
+            _line("WARNING", FTP_LOGGER, "FTP connection timed out to 10.0.0.9"),
+            _line("WARNING", FTP_LOGGER, "FTP connection timed out to 10.0.0.9"),
+            _line("WARNING", FTP_LOGGER, "FTP connection permission error to 10.0.0.9"),
+        ],
+    )
+    ids = [f.signature_id for f in scan_logs().findings]
+    # layer8 error, then layer8 warning, then environment.
+    assert ids == ["ftp-auth-rejected", "ftp-connection-timeout", "database-locked"]
+
+
+def test_every_signature_id_is_unique():
+    ids = [s.id for s in SIGNATURES]
+    assert len(ids) == len(set(ids))

+ 11 - 11
backend/tests/unit/test_support_helpers.py

@@ -235,7 +235,7 @@ class TestSanitizeLogContent:
 
     def test_ipv4_addresses_redacted(self):
         """IPv4 addresses in log lines are replaced with [IP]."""
-        from backend.app.api.routes.support import _sanitize_log_content
+        from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
 
         content = "2024-01-15 Connected to printer at 192.168.1.100 on port 8883"
         result = _sanitize_log_content(content)
@@ -245,7 +245,7 @@ class TestSanitizeLogContent:
 
     def test_multiple_ipv4_addresses_redacted(self):
         """Multiple different IPs in the same line are all redacted."""
-        from backend.app.api.routes.support import _sanitize_log_content
+        from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
 
         content = "Proxy 10.0.0.1 -> 192.168.1.50"
         result = _sanitize_log_content(content)
@@ -253,7 +253,7 @@ class TestSanitizeLogContent:
 
     def test_firmware_versions_with_leading_zeros_preserved(self):
         """Firmware versions like 01.09.01.00 have leading zeros and should NOT be redacted."""
-        from backend.app.api.routes.support import _sanitize_log_content
+        from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
 
         content = "Firmware version: 01.09.01.00"
         result = _sanitize_log_content(content)
@@ -261,7 +261,7 @@ class TestSanitizeLogContent:
 
     def test_firmware_version_mixed_with_ip(self):
         """Firmware versions preserved while real IPs are redacted in the same line."""
-        from backend.app.api.routes.support import _sanitize_log_content
+        from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
 
         content = "Printer at 192.168.1.5 running firmware 01.07.02.00"
         result = _sanitize_log_content(content)
@@ -271,7 +271,7 @@ class TestSanitizeLogContent:
 
     def test_printer_ip_from_sensitive_strings(self):
         """Printer IPs in sensitive_strings are replaced before regex pass."""
-        from backend.app.api.routes.support import _sanitize_log_content
+        from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
 
         content = "Connecting to 192.168.1.100"
         result = _sanitize_log_content(content, sensitive_strings={"192.168.1.100": "[IP]"})
@@ -279,7 +279,7 @@ class TestSanitizeLogContent:
 
     def test_edge_case_zero_ip(self):
         """0.0.0.0 is a valid IP and should be redacted."""
-        from backend.app.api.routes.support import _sanitize_log_content
+        from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
 
         content = "Binding to 0.0.0.0"
         result = _sanitize_log_content(content)
@@ -287,7 +287,7 @@ class TestSanitizeLogContent:
 
     def test_edge_case_broadcast_ip(self):
         """255.255.255.255 is a valid IP and should be redacted."""
-        from backend.app.api.routes.support import _sanitize_log_content
+        from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
 
         content = "Broadcast to 255.255.255.255"
         result = _sanitize_log_content(content)
@@ -295,7 +295,7 @@ class TestSanitizeLogContent:
 
     def test_invalid_octet_not_redacted(self):
         """Octets >255 are not valid IPs and should not be redacted."""
-        from backend.app.api.routes.support import _sanitize_log_content
+        from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
 
         content = "Value 999.999.999.999"
         result = _sanitize_log_content(content)
@@ -303,7 +303,7 @@ class TestSanitizeLogContent:
 
     def test_existing_serial_redaction_still_works(self):
         """Serial number redaction still functions alongside IP redaction."""
-        from backend.app.api.routes.support import _sanitize_log_content
+        from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
 
         content = "Printer 01SABCDEF1234 at 10.0.0.5"
         result = _sanitize_log_content(content)
@@ -314,7 +314,7 @@ class TestSanitizeLogContent:
 
     def test_existing_email_redaction_still_works(self):
         """Email redaction still functions alongside IP redaction."""
-        from backend.app.api.routes.support import _sanitize_log_content
+        from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
 
         content = "User user@example.com from 172.16.0.1"
         result = _sanitize_log_content(content)
@@ -323,7 +323,7 @@ class TestSanitizeLogContent:
 
     def test_existing_path_redaction_still_works(self):
         """Path redaction still functions alongside IP redaction."""
-        from backend.app.api.routes.support import _sanitize_log_content
+        from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
 
         content = "Config at /home/john/config.yaml from 192.168.0.1"
         result = _sanitize_log_content(content)

+ 93 - 0
frontend/src/__tests__/components/AddPrinterPreflight.test.tsx

@@ -0,0 +1,93 @@
+/**
+ * Tests for the Add-Printer setup-time pre-flight.
+ *
+ * On save, the modal runs the connection diagnostic; if any check fails it
+ * warns (rather than blocks) before the printer is added.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { PrintersPage } from '../../pages/PrintersPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+describe('AddPrinterModal pre-flight', () => {
+  beforeEach(() => {
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([])),
+      http.get('/api/v1/queue/', () => HttpResponse.json([])),
+      http.get('/api/v1/discovery/info', () =>
+        HttpResponse.json({ is_docker: false, ssdp_running: false, scan_running: false, subnets: [] }),
+      ),
+    );
+  });
+
+  it('warns instead of saving when a connection check fails', async () => {
+    const user = userEvent.setup();
+    server.use(
+      http.post('/api/v1/printers/diagnostic', () =>
+        HttpResponse.json({
+          printer_id: null,
+          ip_address: '192.168.1.55',
+          overall: 'problems',
+          checks: [{ id: 'developer_mode', status: 'fail', params: {} }],
+        }),
+      ),
+    );
+
+    render(<PrintersPage />);
+    await user.click(await screen.findByText(/add printer/i));
+
+    await user.type(await screen.findByPlaceholderText('My Printer'), 'Test Printer');
+    await user.type(screen.getByPlaceholderText('192.168.1.100 or printer.local'), '192.168.1.55');
+    await user.type(screen.getByPlaceholderText('01P00A000000000'), '01P00A000000000');
+    await user.type(screen.getByPlaceholderText('From printer settings'), '12345678');
+
+    const submit = screen
+      .getAllByRole('button', { name: /add printer/i })
+      .find((b) => b.getAttribute('type') === 'submit')!;
+    await user.click(submit);
+
+    // The failed check surfaces a warning with a "save anyway" escape hatch.
+    expect(await screen.findByText(/Some connection checks failed/i)).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: /save anyway/i })).toBeInTheDocument();
+    expect(screen.getByText(/LAN Developer Mode/i)).toBeInTheDocument();
+  });
+
+  it('saves directly when all connection checks pass', async () => {
+    const user = userEvent.setup();
+    let created = false;
+    server.use(
+      http.post('/api/v1/printers/diagnostic', () =>
+        HttpResponse.json({
+          printer_id: null,
+          ip_address: '192.168.1.55',
+          overall: 'ok',
+          checks: [{ id: 'developer_mode', status: 'pass', params: {} }],
+        }),
+      ),
+      http.post('/api/v1/printers/', async () => {
+        created = true;
+        return HttpResponse.json({ id: 9, name: 'Test Printer' });
+      }),
+    );
+
+    render(<PrintersPage />);
+    await user.click(await screen.findByText(/add printer/i));
+
+    await user.type(await screen.findByPlaceholderText('My Printer'), 'Test Printer');
+    await user.type(screen.getByPlaceholderText('192.168.1.100 or printer.local'), '192.168.1.55');
+    await user.type(screen.getByPlaceholderText('01P00A000000000'), '01P00A000000000');
+    await user.type(screen.getByPlaceholderText('From printer settings'), '12345678');
+
+    const submit = screen
+      .getAllByRole('button', { name: /add printer/i })
+      .find((b) => b.getAttribute('type') === 'submit')!;
+    await user.click(submit);
+
+    await waitFor(() => expect(created).toBe(true));
+    expect(screen.queryByText(/Some connection checks failed/i)).not.toBeInTheDocument();
+  });
+});

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

@@ -282,4 +282,36 @@ describe('BugReportBubble', () => {
     // Single problem → the checklist is expanded without a click.
     expect(await screen.findByText(/Found problems that explain/)).toBeInTheDocument();
   });
+
+  it('shows the log-health panel when the scan finds known issues', async () => {
+    const user = userEvent.setup();
+    setupDiagnosticEndpoints([{ id: 1, name: 'Solo Printer' }], { 1: 'ok' });
+    server.use(
+      http.get('*/system/health', () =>
+        HttpResponse.json({
+          findings: [
+            {
+              signature_id: 'ftp-auth-rejected',
+              severity: 'error',
+              category: 'layer8',
+              wiki_anchor: 'wrong-access-code',
+              count: 3,
+              first_seen: '2026-05-22 09:00:00,000',
+              last_seen: '2026-05-22 10:00:00,000',
+              sample: 'FTP connection permission error to [IP]',
+            },
+          ],
+          scanned_entries: 500,
+          log_available: true,
+          summary: { total: 1, layer8: 1, environment: 0, bug: 0 },
+        })
+      )
+    );
+
+    render(<BugReportBubble />);
+    await user.click(screen.getByRole('button'));
+
+    expect(await screen.findByText('Known issues found in your logs')).toBeInTheDocument();
+    expect(screen.getByText('Printer rejected the access code')).toBeInTheDocument();
+  });
 });

+ 109 - 0
frontend/src/__tests__/components/EditPrinterPreflight.test.tsx

@@ -0,0 +1,109 @@
+/**
+ * Tests for the Edit-Printer setup-time pre-flight.
+ *
+ * Editing a printer runs the same connection diagnostic on save as the
+ * Add-Printer dialog, and warns (rather than blocks) when a check fails.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { PrintersPage } from '../../pages/PrintersPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockPrinter = {
+  id: 1,
+  name: 'X1 Carbon',
+  ip_address: '192.168.1.100',
+  serial_number: '00M09A350100001',
+  access_code: '12345678',
+  model: 'X1C',
+  enabled: true,
+  nozzle_diameter: 0.4,
+  nozzle_type: 'hardened_steel',
+  location: null,
+  auto_archive: true,
+  created_at: '2024-01-01T00:00:00Z',
+  updated_at: '2024-01-01T00:00:00Z',
+};
+
+const mockStatus = {
+  connected: true,
+  state: 'IDLE',
+  progress: 0,
+  layer_num: 0,
+  total_layers: 0,
+  temperatures: { nozzle: 25, bed: 25, chamber: 25 },
+  remaining_time: 0,
+  filename: null,
+  wifi_signal: -50,
+  vt_tray: [],
+};
+
+async function openEditModal() {
+  render(<PrintersPage />);
+  await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());
+
+  // Open the per-printer actions menu (kebab button), then click Edit.
+  const menuBtn = [...document.querySelectorAll('button')].find((b) =>
+    b.querySelector('.lucide-ellipsis-vertical'),
+  )!;
+  await userEvent.click(menuBtn);
+  await userEvent.click(await screen.findByRole('button', { name: /^edit$/i }));
+  await screen.findByText('Edit Printer');
+}
+
+describe('EditPrinterModal pre-flight', () => {
+  beforeEach(() => {
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockStatus)),
+      http.get('/api/v1/queue/', () => HttpResponse.json([])),
+    );
+  });
+
+  it('warns instead of saving when a connection check fails', async () => {
+    server.use(
+      http.post('/api/v1/printers/diagnostic', () =>
+        HttpResponse.json({
+          printer_id: null,
+          ip_address: '192.168.1.100',
+          overall: 'problems',
+          checks: [{ id: 'developer_mode', status: 'fail', params: {} }],
+        }),
+      ),
+    );
+
+    await openEditModal();
+    await userEvent.click(screen.getByRole('button', { name: /save changes/i }));
+
+    expect(await screen.findByText(/Some connection checks failed/i)).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: /save anyway/i })).toBeInTheDocument();
+  });
+
+  it('saves directly when all connection checks pass', async () => {
+    let updated = false;
+    server.use(
+      http.post('/api/v1/printers/diagnostic', () =>
+        HttpResponse.json({
+          printer_id: null,
+          ip_address: '192.168.1.100',
+          overall: 'ok',
+          checks: [{ id: 'developer_mode', status: 'pass', params: {} }],
+        }),
+      ),
+      http.patch('/api/v1/printers/:id', async () => {
+        updated = true;
+        return HttpResponse.json({ ...mockPrinter, name: 'X1 Carbon' });
+      }),
+    );
+
+    await openEditModal();
+    await userEvent.click(screen.getByRole('button', { name: /save changes/i }));
+
+    await waitFor(() => expect(updated).toBe(true));
+    expect(screen.queryByText(/Some connection checks failed/i)).not.toBeInTheDocument();
+  });
+});

+ 74 - 0
frontend/src/__tests__/components/SystemHealthPanel.test.tsx

@@ -0,0 +1,74 @@
+/**
+ * Tests for SystemHealthPanel — the shared log-health result renderer used by
+ * the System page and the bug reporter.
+ *
+ * Covers the three states (clean / log unavailable / findings) and that a
+ * finding renders its localized name, cause, fix, category badge, and a wiki
+ * deep-link built from the signature's anchor.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { I18nextProvider } from 'react-i18next';
+import i18n from '../../i18n';
+import { SystemHealthPanel } from '../../components/SystemHealthPanel';
+import type { SystemHealthResult } from '../../api/client';
+
+function renderPanel(result: SystemHealthResult) {
+  render(
+    <I18nextProvider i18n={i18n}>
+      <SystemHealthPanel result={result} />
+    </I18nextProvider>,
+  );
+}
+
+const BASE: SystemHealthResult = {
+  findings: [],
+  scanned_entries: 1200,
+  log_available: true,
+  summary: { total: 0, layer8: 0, environment: 0, bug: 0 },
+};
+
+describe('SystemHealthPanel', () => {
+  it('shows a healthy message when there are no findings', () => {
+    renderPanel(BASE);
+    expect(screen.getByText(/No known issues found/i)).toBeInTheDocument();
+  });
+
+  it('shows a notice when file logging is unavailable', () => {
+    renderPanel({ ...BASE, log_available: false });
+    expect(screen.getByText(/File logging is disabled/i)).toBeInTheDocument();
+  });
+
+  it('renders a finding with localized name, cause, fix, badge, and wiki link', () => {
+    renderPanel({
+      ...BASE,
+      log_available: true,
+      findings: [
+        {
+          signature_id: 'ftp-auth-rejected',
+          severity: 'error',
+          category: 'layer8',
+          wiki_anchor: 'wrong-access-code',
+          count: 4,
+          first_seen: '2026-05-22 09:00:00,000',
+          last_seen: '2026-05-22 10:00:00,000',
+          sample: 'FTP connection permission error to [IP]',
+        },
+      ],
+      summary: { total: 1, layer8: 1, environment: 0, bug: 0 },
+    });
+
+    expect(screen.getByText('Printer rejected the access code')).toBeInTheDocument();
+    expect(screen.getByText(/refused the file-transfer login/i)).toBeInTheDocument();
+    expect(screen.getByText(/Re-copy the access code/i)).toBeInTheDocument();
+    expect(screen.getByText('You can fix this')).toBeInTheDocument();
+    expect(screen.getByText('FTP connection permission error to [IP]')).toBeInTheDocument();
+
+    const link = screen.getByRole('link', { name: /How to fix/i });
+    expect(link).toHaveAttribute(
+      'href',
+      'https://wiki.bambuddy.cool/reference/troubleshooting/#wrong-access-code',
+    );
+  });
+});

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

@@ -213,6 +213,35 @@ export interface PrinterDiagnosticResult {
   checks: DiagnosticCheck[];
 }
 
+// --- Log-health scan: self-service triage on the System page + bug reporter.
+// The backend matches recent logs against a curated known-issue catalog;
+// human-readable cause/fix text is rendered from i18n keys keyed by signature_id.
+export type LogFindingSeverity = 'error' | 'warning';
+export type LogFindingCategory = 'layer8' | 'environment' | 'bug';
+
+export interface LogFinding {
+  signature_id: string;
+  severity: LogFindingSeverity;
+  category: LogFindingCategory;
+  wiki_anchor: string;
+  count: number;
+  first_seen: string;
+  last_seen: string;
+  sample: string;
+}
+
+export interface SystemHealthResult {
+  findings: LogFinding[];
+  scanned_entries: number;
+  log_available: boolean;
+  summary: {
+    total: number;
+    layer8: number;
+    environment: number;
+    bug: number;
+  };
+}
+
 // Long-lived camera-stream tokens (#1108). The `token` field is populated
 // only on the create response — listing endpoints set it to null because
 // the plaintext value is shown to the user exactly once.
@@ -5351,6 +5380,7 @@ export const api = {
 
   // System Info
   getSystemInfo: () => request<SystemInfo>('/system/info'),
+  getSystemHealth: () => request<SystemHealthResult>('/system/health'),
   getStorageUsage: (options?: { refresh?: boolean }) => {
     const params = new URLSearchParams();
     if (options?.refresh) {

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

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
 import { useQuery } from '@tanstack/react-query';
 import { api, bugReportApi, type PrinterDiagnosticResult } from '../api/client';
 import { DiagnosticChecklist } from './ConnectionDiagnostic';
+import { SystemHealthPanel } from './SystemHealthPanel';
 import { Collapsible } from './Collapsible';
 
 type ViewState = 'form' | 'logging' | 'stopping' | 'submitting' | 'success' | 'error';
@@ -85,6 +86,18 @@ export function BugReportBubble() {
   const diagnosticEntries = diagnosticScan.data ?? [];
   const diagnosticProblems = diagnosticEntries.filter((e) => e.result.overall === 'problems');
 
+  // Scan recent logs against the known-issue catalog. Like the diagnostic
+  // above, this surfaces user-fixable ("layer 8") problems before a report is
+  // filed. Only shown when something matched — a clean scan stays silent so
+  // the form is uncluttered.
+  const logHealthScan = useQuery({
+    queryKey: ['bugReportLogHealth'],
+    enabled: isOpen && viewState === 'form',
+    staleTime: 30_000,
+    queryFn: api.getSystemHealth,
+  });
+  const logFindings = logHealthScan.data?.findings ?? [];
+
   // Elapsed timer for logging phase — auto-stop at 5 minutes
   useEffect(() => {
     if (viewState !== 'logging') return;
@@ -299,6 +312,25 @@ export function BugReportBubble() {
                       </div>
                     )}
 
+                  {/* Log-health scan — known issues found in recent logs.
+                      Shown only when something matched. */}
+                  {!logHealthScan.isLoading && logFindings.length > 0 && logHealthScan.data && (
+                    <div className="rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-3 space-y-3">
+                      <div className="flex items-start gap-2">
+                        <Stethoscope className="w-4 h-4 mt-0.5 flex-shrink-0 text-amber-600 dark:text-amber-400" />
+                        <div>
+                          <p className="text-sm font-medium text-amber-700 dark:text-amber-300">
+                            {t('bugReport.logHealthSummary')}
+                          </p>
+                          <p className="text-xs text-amber-800 dark:text-amber-200 mt-0.5">
+                            {t('bugReport.logHealthIntro')}
+                          </p>
+                        </div>
+                      </div>
+                      <SystemHealthPanel result={logHealthScan.data} />
+                    </div>
+                  )}
+
                   {/* Description */}
                   <div>
                     <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">

+ 107 - 0
frontend/src/components/SystemHealthPanel.tsx

@@ -0,0 +1,107 @@
+import type { ElementType } from 'react';
+import { useTranslation } from 'react-i18next';
+import { XCircle, AlertTriangle, CheckCircle2, ExternalLink, Wrench, ServerCog, Bug } from 'lucide-react';
+import type { LogFinding, LogFindingCategory, SystemHealthResult } from '../api/client';
+
+const WIKI_TROUBLESHOOTING = 'https://wiki.bambuddy.cool/reference/troubleshooting/';
+
+const CATEGORY_META: Record<LogFindingCategory, { icon: ElementType; badgeClass: string }> = {
+  layer8: { icon: Wrench, badgeClass: 'bg-bambu-green/15 text-bambu-green border-bambu-green/30' },
+  environment: { icon: ServerCog, badgeClass: 'bg-amber-500/15 text-amber-300 border-amber-500/30' },
+  bug: { icon: Bug, badgeClass: 'bg-red-500/15 text-red-300 border-red-500/30' },
+};
+
+/**
+ * One detected log-health finding. Cause/fix/name text is rendered from i18n
+ * keyed by signature_id; an unknown signature (frontend older than backend)
+ * still renders gracefully via the defaultValue fallbacks.
+ */
+function FindingCard({ finding }: { finding: LogFinding }) {
+  const { t } = useTranslation();
+  const id = finding.signature_id;
+  const name = t(`systemHealth.signature.${id}.name`, { defaultValue: id });
+  const cause = t(`systemHealth.signature.${id}.cause`, { defaultValue: '' });
+  const fix = t(`systemHealth.signature.${id}.fix`, { defaultValue: '' });
+  const meta = CATEGORY_META[finding.category] ?? CATEGORY_META.bug;
+  const CategoryIcon = meta.icon;
+  const SeverityIcon = finding.severity === 'error' ? XCircle : AlertTriangle;
+  const severityColor = finding.severity === 'error' ? 'text-red-400' : 'text-amber-400';
+
+  return (
+    <div className="bg-bambu-dark rounded-lg border border-bambu-dark-tertiary p-4 space-y-2">
+      <div className="flex items-start gap-3">
+        <SeverityIcon className={`w-5 h-5 flex-shrink-0 mt-0.5 ${severityColor}`} />
+        <div className="flex-1 min-w-0">
+          <div className="flex items-center gap-2 flex-wrap">
+            <span className="text-sm font-medium text-white">{name}</span>
+            <span
+              className={`inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full border ${meta.badgeClass}`}
+            >
+              <CategoryIcon className="w-3 h-3" />
+              {t(`systemHealth.category.${finding.category}`)}
+            </span>
+          </div>
+          {cause && <p className="text-xs text-bambu-gray mt-1">{cause}</p>}
+        </div>
+      </div>
+
+      {fix && (
+        <div className="text-xs text-white/90 bg-bambu-dark-secondary rounded px-3 py-2">
+          <span className="text-bambu-green font-medium">{t('systemHealth.fixLabel')}</span> {fix}
+        </div>
+      )}
+
+      <div className="text-xs font-mono text-bambu-gray/70 bg-bambu-dark-secondary rounded px-3 py-2 break-all">
+        {finding.sample}
+      </div>
+
+      <div className="flex items-center justify-between gap-2 flex-wrap">
+        <span className="text-xs text-bambu-gray">
+          {t('systemHealth.occurrences', { times: finding.count, lastSeen: finding.last_seen })}
+        </span>
+        <a
+          href={`${WIKI_TROUBLESHOOTING}#${finding.wiki_anchor}`}
+          target="_blank"
+          rel="noopener noreferrer"
+          className="inline-flex items-center gap-1 text-xs text-bambu-green hover:underline"
+        >
+          {t('systemHealth.learnMore')}
+          <ExternalLink className="w-3 h-3" />
+        </a>
+      </div>
+    </div>
+  );
+}
+
+/**
+ * Presentational panel for a log-health scan result. Shared by the System page
+ * section and the bug-report bubble so both surfaces look identical.
+ */
+export function SystemHealthPanel({ result }: { result: SystemHealthResult }) {
+  const { t } = useTranslation();
+
+  if (!result.log_available) {
+    return (
+      <div className="rounded-lg bg-amber-500/10 border border-amber-500/30 px-4 py-3 text-sm text-amber-300">
+        {t('systemHealth.logUnavailable')}
+      </div>
+    );
+  }
+
+  if (result.findings.length === 0) {
+    return (
+      <div className="rounded-lg bg-bambu-green/10 border border-bambu-green/30 px-4 py-3 text-sm text-bambu-green flex items-center gap-2">
+        <CheckCircle2 className="w-5 h-5 flex-shrink-0" />
+        <span>{t('systemHealth.clean', { times: result.scanned_entries })}</span>
+      </div>
+    );
+  }
+
+  return (
+    <div className="space-y-3">
+      {result.findings.map((finding) => (
+        <FindingCard key={finding.signature_id} finding={finding} />
+      ))}
+    </div>
+  );
+}

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

@@ -124,6 +124,12 @@ export default {
 
   // Printers page
   printers: {
+    addPreflight: {
+      checking: 'Verbindung wird geprüft...',
+      warning: 'Einige Verbindungsprüfungen sind fehlgeschlagen. Dieser Drucker wird möglicherweise als offline angezeigt. Prüfe die Punkte unten, behebe was möglich ist, oder speichere trotzdem.',
+      back: 'Zurück',
+      saveAnyway: 'Trotzdem speichern',
+    },
     title: 'Drucker',
     addPrinter: 'Drucker hinzufügen',
     editPrinter: 'Drucker bearbeiten',
@@ -5534,6 +5540,54 @@ export default {
     },
   },
 
+  systemHealth: {
+    sectionTitle: 'Systemzustand',
+    sectionDescription: 'Durchsucht aktuelle Logs nach bekannten Problemen, die du meist selbst beheben kannst, bevor daraus ein Support-Ticket wird.',
+    rescan: 'Erneut prüfen',
+    clean: 'Keine bekannten Probleme in den letzten {{times}} Log-Einträgen gefunden.',
+    logUnavailable: 'Die Protokollierung in Dateien ist deaktiviert, daher können keine Logs durchsucht werden. Aktiviere die Datei-Protokollierung, um diese Prüfung zu nutzen.',
+    learnMore: 'Lösung anzeigen',
+    fixLabel: 'Lösung:',
+    occurrences: '{{times}}× aufgetreten — zuletzt um {{lastSeen}}',
+    category: {
+      layer8: 'Das kannst du beheben',
+      environment: 'Umgebung',
+      bug: 'Bitte melden',
+    },
+    signature: {
+      'ftp-auth-rejected': {
+        name: 'Drucker hat den Zugriffscode abgelehnt',
+        cause: 'Der Drucker hat die Anmeldung für die Dateiübertragung abgelehnt. Der Zugriffscode ist falsch oder hat sich nach dem Umschalten des Entwicklermodus geändert.',
+        fix: 'Kopiere den Zugriffscode erneut vom Druckerbildschirm (LAN-Einstellungen) und aktualisiere ihn in den Druckereinstellungen in Bambuddy.',
+      },
+      'ftp-connection-timeout': {
+        name: 'Zeitüberschreitung bei der Dateiübertragung',
+        cause: 'Bambuddy konnte den Dateiübertragungs-Port des Druckers (FTPS 990) nicht erreichen. Der Port ist blockiert, oder der Drucker ist aus oder in einem anderen Subnetz.',
+        fix: 'Stelle sicher, dass Port 990 zwischen Bambuddy und dem Drucker nicht blockiert wird und beide im selben Netzwerk sind.',
+      },
+      'ftp-ssl-error': {
+        name: 'Sicherer Dateiübertragungs-Handshake fehlgeschlagen',
+        cause: 'Der TLS-Handshake mit dem Dateiübertragungs-Server des Druckers ist fehlgeschlagen. Häufig liegt das an einer Firewall oder veralteter Drucker-Firmware.',
+        fix: 'Aktualisiere die Drucker-Firmware und prüfe, dass keine Firewall oder Proxy die Verbindung auf Port 990 abfängt.',
+      },
+      'mqtt-connection-flapping': {
+        name: 'Druckerverbindung bricht ständig ab',
+        cause: 'Die Steuerverbindung (MQTT 8883) trennt und verbindet sich wiederholt — meist wegen einer schwachen Netzwerkverbindung oder eines teilweise blockierten Ports.',
+        fix: 'Prüfe das WLAN-Signal am Drucker, bevorzuge eine Kabelverbindung und stelle sicher, dass Port 8883 zuverlässig erreichbar ist.',
+      },
+      'camera-connection-refused': {
+        name: 'Kamera-Stream nicht erreichbar',
+        cause: 'Die Live-Kamera war auf Port RTSPS 322 nicht erreichbar. Der Port ist blockiert, oder die Kamera bzw. die LAN-Liveansicht ist am Drucker deaktiviert.',
+        fix: 'Aktiviere Kamera und LAN-Liveansicht am Drucker und stelle sicher, dass Port 322 nicht blockiert ist. Das Drucken ist davon nicht betroffen.',
+      },
+      'database-locked': {
+        name: 'Datenbank-Schreibkonflikte',
+        cause: 'Die SQLite-Datenbank meldet unter Last "database is locked"-Fehler — häufig beim Betrieb mehrerer Drucker gleichzeitig.',
+        fix: 'Stelle Bambuddy auf eine externe PostgreSQL-Datenbank um. Siehe die PostgreSQL-Anleitung in der Dokumentation.',
+      },
+    },
+  },
+
   vpDiagnostic: {
     title: 'Einrichtungsprüfung — {{name}}',
     runButton: 'Einrichtungsprüfung ausführen',
@@ -5588,6 +5642,8 @@ export default {
   },
 
   bugReport: {
+    logHealthSummary: 'Bekannte Probleme in deinen Logs gefunden',
+    logHealthIntro: 'Aktuelle Logs entsprechen bekannten Problemen. Sieh dir die Lösungen unten an — sie zu beheben könnte dein Problem ohne Fehlerbericht lösen. Du kannst unten trotzdem einen Bericht senden.',
     title: 'Fehler melden',
     description: 'Beschreibung',
     descriptionPlaceholder: 'Was ist schiefgelaufen? Bitte beschreiben Sie das Problem...',

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

@@ -126,6 +126,12 @@ export default {
   printers: {
     title: 'Printers',
     addPrinter: 'Add Printer',
+    addPreflight: {
+      checking: 'Checking connection...',
+      warning: 'Some connection checks failed. This printer may show as offline. Review the checks below, fix what you can, or save anyway.',
+      back: 'Back',
+      saveAnyway: 'Save anyway',
+    },
     editPrinter: 'Edit Printer',
     deletePrinter: 'Delete Printer',
     printerName: 'Printer Name',
@@ -5547,6 +5553,54 @@ export default {
     },
   },
 
+  systemHealth: {
+    sectionTitle: 'System Health',
+    sectionDescription: 'Scans recent logs for known issues you can usually fix yourself, before they turn into a support ticket.',
+    rescan: 'Re-scan',
+    clean: 'No known issues found in the last {{times}} log entries.',
+    logUnavailable: 'File logging is disabled, so logs cannot be scanned. Enable file logging to use this check.',
+    learnMore: 'How to fix',
+    fixLabel: 'Fix:',
+    occurrences: 'Seen {{times}}× — last at {{lastSeen}}',
+    category: {
+      layer8: 'You can fix this',
+      environment: 'Environment',
+      bug: 'Please report this',
+    },
+    signature: {
+      'ftp-auth-rejected': {
+        name: 'Printer rejected the access code',
+        cause: 'The printer refused the file-transfer login. The access code is wrong, or it changed after Developer Mode was toggled.',
+        fix: 'Re-copy the access code from the printer screen (LAN settings) and update it in the printer\'s settings in Bambuddy.',
+      },
+      'ftp-connection-timeout': {
+        name: 'File-transfer connection timed out',
+        cause: 'Bambuddy could not reach the printer\'s file-transfer port (FTPS 990). The port is blocked, or the printer is off or on another subnet.',
+        fix: 'Make sure nothing blocks port 990 between Bambuddy and the printer, and that both are on the same network.',
+      },
+      'ftp-ssl-error': {
+        name: 'Secure file-transfer handshake failed',
+        cause: 'The TLS handshake with the printer\'s file-transfer server failed. This is often a firewall or outdated printer firmware.',
+        fix: 'Update the printer firmware and check that no firewall or proxy intercepts the connection on port 990.',
+      },
+      'mqtt-connection-flapping': {
+        name: 'Printer connection keeps dropping',
+        cause: 'The control connection (MQTT 8883) repeatedly disconnects and reconnects — usually a weak network path or a partially blocked port.',
+        fix: 'Check the Wi-Fi signal at the printer, prefer a wired connection, and make sure port 8883 is reliably reachable.',
+      },
+      'camera-connection-refused': {
+        name: 'Camera stream unreachable',
+        cause: 'The live camera could not be reached on port RTSPS 322. The port is blocked, or the camera or LAN liveview is off on the printer.',
+        fix: 'Enable the camera and LAN liveview on the printer, and make sure port 322 is not blocked. This does not affect printing.',
+      },
+      'database-locked': {
+        name: 'Database write contention',
+        cause: 'The SQLite database is hitting "database is locked" errors under load — common when running several printers at once.',
+        fix: 'Switch Bambuddy to an external PostgreSQL database. See the PostgreSQL guide in the documentation.',
+      },
+    },
+  },
+
   vpDiagnostic: {
     title: 'Setup check — {{name}}',
     runButton: 'Run setup check',
@@ -5629,6 +5683,8 @@ export default {
     diagnosticHealthy: 'Connection check passed — no problems found on your printers.',
     diagnosticSummary: '{{problems}} of {{total}} printers have connection issues',
     diagnosticIntro: 'One or more printers have a connection problem that may be causing your issue. Expand a printer below to see the fix — resolving it could solve the problem without a bug report. You can still submit a report below.',
+    logHealthSummary: 'Known issues found in your logs',
+    logHealthIntro: 'Recent logs match known problems. Check the fixes below — resolving them could solve your issue without a bug report. You can still submit a report below.',
     thankYou: 'Thank you!',
     submitted: 'Your bug report has been submitted.',
     viewIssue: 'View Issue',

+ 56 - 0
frontend/src/i18n/locales/es.ts

@@ -124,6 +124,12 @@ export default {
 
   // Printers page
   printers: {
+    addPreflight: {
+      checking: 'Comprobando la conexión...',
+      warning: 'Algunas comprobaciones de conexión fallaron. Esta impresora podría aparecer como desconectada. Revisa las comprobaciones de abajo, soluciona lo que puedas o guárdala de todos modos.',
+      back: 'Atrás',
+      saveAnyway: 'Guardar de todos modos',
+    },
     title: 'Impresoras',
     addPrinter: 'Añadir impresora',
     editPrinter: 'Editar impresora',
@@ -5543,6 +5549,54 @@ export default {
     },
   },
 
+  systemHealth: {
+    sectionTitle: 'Estado del sistema',
+    sectionDescription: 'Analiza los registros recientes en busca de problemas conocidos que normalmente puedes resolver tú mismo, antes de que se conviertan en un ticket de soporte.',
+    rescan: 'Volver a analizar',
+    clean: 'No se encontraron problemas conocidos en las últimas {{times}} entradas de registro.',
+    logUnavailable: 'El registro en archivo está desactivado, por lo que no se pueden analizar los registros. Activa el registro en archivo para usar esta comprobación.',
+    learnMore: 'Cómo solucionarlo',
+    fixLabel: 'Solución:',
+    occurrences: 'Visto {{times}}× — última vez a las {{lastSeen}}',
+    category: {
+      layer8: 'Puedes solucionarlo',
+      environment: 'Entorno',
+      bug: 'Por favor, repórtalo',
+    },
+    signature: {
+      'ftp-auth-rejected': {
+        name: 'La impresora rechazó el código de acceso',
+        cause: 'La impresora rechazó el inicio de sesión de transferencia de archivos. El código de acceso es incorrecto o cambió tras activar el Modo Desarrollador.',
+        fix: 'Vuelve a copiar el código de acceso desde la pantalla de la impresora (ajustes de LAN) y actualízalo en los ajustes de la impresora en Bambuddy.',
+      },
+      'ftp-connection-timeout': {
+        name: 'Se agotó el tiempo de la conexión de transferencia de archivos',
+        cause: 'Bambuddy no pudo alcanzar el puerto de transferencia de archivos de la impresora (FTPS 990). El puerto está bloqueado, o la impresora está apagada o en otra subred.',
+        fix: 'Asegúrate de que nada bloquee el puerto 990 entre Bambuddy y la impresora, y de que ambos estén en la misma red.',
+      },
+      'ftp-ssl-error': {
+        name: 'Falló el protocolo de enlace seguro de transferencia de archivos',
+        cause: 'El protocolo de enlace TLS con el servidor de transferencia de archivos de la impresora falló. Suele deberse a un cortafuegos o a un firmware de impresora desactualizado.',
+        fix: 'Actualiza el firmware de la impresora y comprueba que ningún cortafuegos o proxy intercepte la conexión en el puerto 990.',
+      },
+      'mqtt-connection-flapping': {
+        name: 'La conexión con la impresora se cae continuamente',
+        cause: 'La conexión de control (MQTT 8883) se desconecta y reconecta repetidamente, normalmente por una red débil o un puerto parcialmente bloqueado.',
+        fix: 'Comprueba la señal Wi-Fi en la impresora, usa preferentemente una conexión por cable y asegúrate de que el puerto 8883 sea accesible de forma fiable.',
+      },
+      'camera-connection-refused': {
+        name: 'Transmisión de cámara inaccesible',
+        cause: 'No se pudo acceder a la cámara en vivo en el puerto RTSPS 322. El puerto está bloqueado, o la cámara o la vista en vivo por LAN están desactivadas en la impresora.',
+        fix: 'Activa la cámara y la vista en vivo por LAN en la impresora y asegúrate de que el puerto 322 no esté bloqueado. Esto no afecta a la impresión.',
+      },
+      'database-locked': {
+        name: 'Conflictos de escritura en la base de datos',
+        cause: 'La base de datos SQLite presenta errores "database is locked" bajo carga, algo habitual al usar varias impresoras a la vez.',
+        fix: 'Cambia Bambuddy a una base de datos PostgreSQL externa. Consulta la guía de PostgreSQL en la documentación.',
+      },
+    },
+  },
+
   vpDiagnostic: {
     title: 'Comprobación de configuración — {{name}}',
     runButton: 'Ejecutar comprobación de configuración',
@@ -5597,6 +5651,8 @@ export default {
   },
 
   bugReport: {
+    logHealthSummary: 'Se encontraron problemas conocidos en tus registros',
+    logHealthIntro: 'Los registros recientes coinciden con problemas conocidos. Revisa las soluciones de abajo: resolverlas podría arreglar tu problema sin un informe de error. Aun así, puedes enviar un informe abajo.',
     title: 'Informar de un error',
     description: 'Descripción',
     descriptionPlaceholder: '¿Qué salió mal? Describa el problema...',

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

@@ -124,6 +124,12 @@ export default {
 
   // Printers page
   printers: {
+    addPreflight: {
+      checking: 'Vérification de la connexion...',
+      warning: 'Certaines vérifications de connexion ont échoué. Cette imprimante pourrait apparaître hors ligne. Examinez les vérifications ci-dessous, corrigez ce que vous pouvez, ou enregistrez quand même.',
+      back: 'Retour',
+      saveAnyway: 'Enregistrer quand même',
+    },
     title: 'Imprimantes',
     addPrinter: 'Ajouter une imprimante',
     editPrinter: 'Modifier l\'imprimante',
@@ -5524,6 +5530,54 @@ export default {
     },
   },
 
+  systemHealth: {
+    sectionTitle: 'État du système',
+    sectionDescription: 'Analyse les journaux récents à la recherche de problèmes connus que vous pouvez généralement résoudre vous-même, avant qu\'ils ne deviennent un ticket de support.',
+    rescan: 'Relancer l\'analyse',
+    clean: 'Aucun problème connu trouvé dans les {{times}} dernières entrées de journal.',
+    logUnavailable: 'La journalisation dans un fichier est désactivée, les journaux ne peuvent donc pas être analysés. Activez la journalisation dans un fichier pour utiliser cette vérification.',
+    learnMore: 'Comment corriger',
+    fixLabel: 'Correctif :',
+    occurrences: 'Vu {{times}}× — dernière fois à {{lastSeen}}',
+    category: {
+      layer8: 'Vous pouvez corriger ceci',
+      environment: 'Environnement',
+      bug: 'Merci de le signaler',
+    },
+    signature: {
+      'ftp-auth-rejected': {
+        name: 'L\'imprimante a refusé le code d\'accès',
+        cause: 'L\'imprimante a refusé la connexion de transfert de fichiers. Le code d\'accès est incorrect, ou il a changé après l\'activation du mode développeur.',
+        fix: 'Recopiez le code d\'accès depuis l\'écran de l\'imprimante (paramètres LAN) et mettez-le à jour dans les paramètres de l\'imprimante dans Bambuddy.',
+      },
+      'ftp-connection-timeout': {
+        name: 'Délai de connexion de transfert de fichiers dépassé',
+        cause: 'Bambuddy n\'a pas pu atteindre le port de transfert de fichiers de l\'imprimante (FTPS 990). Le port est bloqué, ou l\'imprimante est éteinte ou sur un autre sous-réseau.',
+        fix: 'Assurez-vous que rien ne bloque le port 990 entre Bambuddy et l\'imprimante, et que les deux sont sur le même réseau.',
+      },
+      'ftp-ssl-error': {
+        name: 'Échec de la négociation sécurisée du transfert de fichiers',
+        cause: 'La négociation TLS avec le serveur de transfert de fichiers de l\'imprimante a échoué. C\'est souvent dû à un pare-feu ou à un micrologiciel d\'imprimante obsolète.',
+        fix: 'Mettez à jour le micrologiciel de l\'imprimante et vérifiez qu\'aucun pare-feu ou proxy n\'intercepte la connexion sur le port 990.',
+      },
+      'mqtt-connection-flapping': {
+        name: 'La connexion à l\'imprimante se coupe sans cesse',
+        cause: 'La connexion de contrôle (MQTT 8883) se déconnecte et se reconnecte à répétition — généralement à cause d\'un réseau faible ou d\'un port partiellement bloqué.',
+        fix: 'Vérifiez le signal Wi-Fi près de l\'imprimante, privilégiez une connexion filaire et assurez-vous que le port 8883 est accessible de façon fiable.',
+      },
+      'camera-connection-refused': {
+        name: 'Flux de la caméra inaccessible',
+        cause: 'La caméra en direct n\'a pas pu être atteinte sur le port RTSPS 322. Le port est bloqué, ou la caméra ou la vue en direct par LAN est désactivée sur l\'imprimante.',
+        fix: 'Activez la caméra et la vue en direct par LAN sur l\'imprimante, et assurez-vous que le port 322 n\'est pas bloqué. Cela n\'affecte pas l\'impression.',
+      },
+      'database-locked': {
+        name: 'Conflits d\'écriture dans la base de données',
+        cause: 'La base de données SQLite rencontre des erreurs "database is locked" sous charge — fréquent lorsque plusieurs imprimantes sont utilisées en même temps.',
+        fix: 'Faites passer Bambuddy à une base de données PostgreSQL externe. Consultez le guide PostgreSQL dans la documentation.',
+      },
+    },
+  },
+
   vpDiagnostic: {
     title: 'Vérification de configuration — {{name}}',
     runButton: 'Lancer la vérification',
@@ -5578,6 +5632,8 @@ export default {
   },
 
   bugReport: {
+    logHealthSummary: 'Problèmes connus trouvés dans vos journaux',
+    logHealthIntro: 'Les journaux récents correspondent à des problèmes connus. Consultez les correctifs ci-dessous — les résoudre pourrait régler votre problème sans rapport de bogue. Vous pouvez tout de même envoyer un rapport ci-dessous.',
     title: 'Signaler un bug',
     description: 'Description',
     descriptionPlaceholder: 'Qu\'est-ce qui n\'a pas fonctionné ? Veuillez décrire le problème...',

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

@@ -124,6 +124,12 @@ export default {
 
   // Printers page
   printers: {
+    addPreflight: {
+      checking: 'Verifica della connessione...',
+      warning: 'Alcuni controlli di connessione non sono riusciti. Questa stampante potrebbe risultare offline. Controlla le verifiche qui sotto, risolvi ciò che puoi oppure salva comunque.',
+      back: 'Indietro',
+      saveAnyway: 'Salva comunque',
+    },
     title: 'Stampanti',
     addPrinter: 'Aggiungi Stampante',
     editPrinter: 'Modifica Stampante',
@@ -5523,6 +5529,54 @@ export default {
     },
   },
 
+  systemHealth: {
+    sectionTitle: 'Stato del sistema',
+    sectionDescription: 'Analizza i log recenti alla ricerca di problemi noti che di solito puoi risolvere da solo, prima che diventino un ticket di assistenza.',
+    rescan: 'Analizza di nuovo',
+    clean: 'Nessun problema noto trovato nelle ultime {{times}} voci di log.',
+    logUnavailable: 'La registrazione su file è disattivata, quindi i log non possono essere analizzati. Attiva la registrazione su file per usare questo controllo.',
+    learnMore: 'Come risolvere',
+    fixLabel: 'Soluzione:',
+    occurrences: 'Visto {{times}}× — ultima volta alle {{lastSeen}}',
+    category: {
+      layer8: 'Puoi risolverlo tu',
+      environment: 'Ambiente',
+      bug: 'Segnalalo, per favore',
+    },
+    signature: {
+      'ftp-auth-rejected': {
+        name: 'La stampante ha rifiutato il codice di accesso',
+        cause: 'La stampante ha rifiutato l\'accesso per il trasferimento file. Il codice di accesso è errato oppure è cambiato dopo aver attivato la Modalità Sviluppatore.',
+        fix: 'Ricopia il codice di accesso dallo schermo della stampante (impostazioni LAN) e aggiornalo nelle impostazioni della stampante in Bambuddy.',
+      },
+      'ftp-connection-timeout': {
+        name: 'Timeout della connessione di trasferimento file',
+        cause: 'Bambuddy non è riuscito a raggiungere la porta di trasferimento file della stampante (FTPS 990). La porta è bloccata, oppure la stampante è spenta o in un\'altra sottorete.',
+        fix: 'Assicurati che nulla blocchi la porta 990 tra Bambuddy e la stampante e che entrambi siano sulla stessa rete.',
+      },
+      'ftp-ssl-error': {
+        name: 'Handshake sicuro del trasferimento file non riuscito',
+        cause: 'L\'handshake TLS con il server di trasferimento file della stampante non è riuscito. Spesso è dovuto a un firewall o a un firmware della stampante obsoleto.',
+        fix: 'Aggiorna il firmware della stampante e verifica che nessun firewall o proxy intercetti la connessione sulla porta 990.',
+      },
+      'mqtt-connection-flapping': {
+        name: 'La connessione alla stampante cade di continuo',
+        cause: 'La connessione di controllo (MQTT 8883) si disconnette e si riconnette ripetutamente, di solito per una rete debole o una porta parzialmente bloccata.',
+        fix: 'Controlla il segnale Wi-Fi vicino alla stampante, preferisci una connessione cablata e assicurati che la porta 8883 sia raggiungibile in modo affidabile.',
+      },
+      'camera-connection-refused': {
+        name: 'Flusso della telecamera non raggiungibile',
+        cause: 'Non è stato possibile raggiungere la telecamera dal vivo sulla porta RTSPS 322. La porta è bloccata, oppure la telecamera o la visione dal vivo via LAN è disattivata sulla stampante.',
+        fix: 'Attiva la telecamera e la visione dal vivo via LAN sulla stampante e assicurati che la porta 322 non sia bloccata. Questo non influisce sulla stampa.',
+      },
+      'database-locked': {
+        name: 'Conflitti di scrittura nel database',
+        cause: 'Il database SQLite presenta errori "database is locked" sotto carico, comune quando si usano più stampanti contemporaneamente.',
+        fix: 'Passa Bambuddy a un database PostgreSQL esterno. Consulta la guida PostgreSQL nella documentazione.',
+      },
+    },
+  },
+
   vpDiagnostic: {
     title: 'Controllo configurazione — {{name}}',
     runButton: 'Esegui controllo configurazione',
@@ -5577,6 +5631,8 @@ export default {
   },
 
   bugReport: {
+    logHealthSummary: 'Trovati problemi noti nei tuoi log',
+    logHealthIntro: 'I log recenti corrispondono a problemi noti. Controlla le soluzioni qui sotto: risolverle potrebbe sistemare il problema senza una segnalazione di bug. Puoi comunque inviare una segnalazione qui sotto.',
     title: 'Segnala un bug',
     description: 'Descrizione',
     descriptionPlaceholder: 'Cosa è andato storto? Descrivi il problema...',

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

@@ -123,6 +123,12 @@ export default {
   },
   // Printers page
   printers: {
+    addPreflight: {
+      checking: '接続を確認しています...',
+      warning: '一部の接続チェックに失敗しました。このプリンターはオフラインと表示される可能性があります。下のチェックを確認し、可能な範囲で修正するか、そのまま保存してください。',
+      back: '戻る',
+      saveAnyway: 'そのまま保存',
+    },
     title: 'プリンター',
     addPrinter: 'プリンターを追加',
     editPrinter: 'プリンターを編集',
@@ -5535,6 +5541,54 @@ export default {
     },
   },
 
+  systemHealth: {
+    sectionTitle: 'システムの状態',
+    sectionDescription: '最近のログから既知の問題を検出します。サポートに問い合わせる前に、多くは自分で解決できます。',
+    rescan: '再スキャン',
+    clean: '直近 {{times}} 件のログに既知の問題は見つかりませんでした。',
+    logUnavailable: 'ファイルへのログ記録が無効になっているため、ログをスキャンできません。このチェックを使うにはファイルログを有効にしてください。',
+    learnMore: '対処方法',
+    fixLabel: '対処:',
+    occurrences: '{{times}} 回発生 — 最終 {{lastSeen}}',
+    category: {
+      layer8: '自分で解決できます',
+      environment: '環境',
+      bug: '報告してください',
+    },
+    signature: {
+      'ftp-auth-rejected': {
+        name: 'プリンターがアクセスコードを拒否しました',
+        cause: 'プリンターがファイル転送のログインを拒否しました。アクセスコードが間違っているか、開発者モードの切り替え後に変更されています。',
+        fix: 'プリンター画面(LAN設定)からアクセスコードをコピーし直し、Bambuddy のプリンター設定で更新してください。',
+      },
+      'ftp-connection-timeout': {
+        name: 'ファイル転送の接続がタイムアウトしました',
+        cause: 'Bambuddy がプリンターのファイル転送ポート(FTPS 990)に到達できませんでした。ポートがブロックされているか、プリンターの電源が切れているか、別のサブネットにあります。',
+        fix: 'Bambuddy とプリンターの間でポート 990 がブロックされていないこと、両者が同じネットワークにあることを確認してください。',
+      },
+      'ftp-ssl-error': {
+        name: 'セキュアなファイル転送のハンドシェイクに失敗しました',
+        cause: 'プリンターのファイル転送サーバーとの TLS ハンドシェイクに失敗しました。多くはファイアウォールか、プリンターのファームウェアが古いことが原因です。',
+        fix: 'プリンターのファームウェアを更新し、ファイアウォールやプロキシがポート 990 の接続を妨げていないか確認してください。',
+      },
+      'mqtt-connection-flapping': {
+        name: 'プリンター接続が繰り返し切断されます',
+        cause: '制御接続(MQTT 8883)が切断と再接続を繰り返しています。通常はネットワークが弱いか、ポートが部分的にブロックされていることが原因です。',
+        fix: 'プリンター付近の Wi-Fi 信号を確認し、できれば有線接続を使用し、ポート 8883 が安定して到達できることを確認してください。',
+      },
+      'camera-connection-refused': {
+        name: 'カメラ映像に接続できません',
+        cause: 'ポート RTSPS 322 でライブカメラに到達できませんでした。ポートがブロックされているか、プリンターのカメラまたは LAN ライブビューが無効になっています。',
+        fix: 'プリンターのカメラと LAN ライブビューを有効にし、ポート 322 がブロックされていないか確認してください。これは印刷には影響しません。',
+      },
+      'database-locked': {
+        name: 'データベースの書き込み競合',
+        cause: '負荷時に SQLite データベースで "database is locked" エラーが発生しています。複数のプリンターを同時に使用する場合によく起こります。',
+        fix: 'Bambuddy を外部の PostgreSQL データベースに切り替えてください。ドキュメントの PostgreSQL ガイドを参照してください。',
+      },
+    },
+  },
+
   vpDiagnostic: {
     title: 'セットアップチェック — {{name}}',
     runButton: 'セットアップチェックを実行',
@@ -5589,6 +5643,8 @@ export default {
   },
 
   bugReport: {
+    logHealthSummary: 'ログから既知の問題が見つかりました',
+    logHealthIntro: '最近のログが既知の問題と一致しています。下記の対処方法をご確認ください。解決すればバグ報告なしで問題が解消する可能性があります。下からバグ報告を送ることもできます。',
     title: 'バグを報告',
     description: '説明',
     descriptionPlaceholder: '何が問題でしたか?問題を説明してください...',

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

@@ -124,6 +124,12 @@ export default {
 
   // Printers page
   printers: {
+    addPreflight: {
+      checking: 'Verificando a conexão...',
+      warning: 'Algumas verificações de conexão falharam. Esta impressora pode aparecer como offline. Revise as verificações abaixo, corrija o que puder ou salve mesmo assim.',
+      back: 'Voltar',
+      saveAnyway: 'Salvar mesmo assim',
+    },
     title: 'Impressoras',
     addPrinter: 'Adicionar Impressora',
     editPrinter: 'Editar Impressora',
@@ -5523,6 +5529,54 @@ export default {
     },
   },
 
+  systemHealth: {
+    sectionTitle: 'Saúde do sistema',
+    sectionDescription: 'Analisa os logs recentes em busca de problemas conhecidos que você normalmente pode resolver sozinho, antes que virem um chamado de suporte.',
+    rescan: 'Analisar novamente',
+    clean: 'Nenhum problema conhecido encontrado nas últimas {{times}} entradas de log.',
+    logUnavailable: 'O registro em arquivo está desativado, então os logs não podem ser analisados. Ative o registro em arquivo para usar esta verificação.',
+    learnMore: 'Como corrigir',
+    fixLabel: 'Correção:',
+    occurrences: 'Visto {{times}}× — última vez às {{lastSeen}}',
+    category: {
+      layer8: 'Você pode corrigir isto',
+      environment: 'Ambiente',
+      bug: 'Por favor, relate isto',
+    },
+    signature: {
+      'ftp-auth-rejected': {
+        name: 'A impressora rejeitou o código de acesso',
+        cause: 'A impressora recusou o login de transferência de arquivos. O código de acesso está errado ou mudou após alternar o Modo Desenvolvedor.',
+        fix: 'Copie novamente o código de acesso da tela da impressora (configurações de LAN) e atualize-o nas configurações da impressora no Bambuddy.',
+      },
+      'ftp-connection-timeout': {
+        name: 'Tempo limite da conexão de transferência de arquivos',
+        cause: 'O Bambuddy não conseguiu acessar a porta de transferência de arquivos da impressora (FTPS 990). A porta está bloqueada, ou a impressora está desligada ou em outra sub-rede.',
+        fix: 'Verifique se nada bloqueia a porta 990 entre o Bambuddy e a impressora e se ambos estão na mesma rede.',
+      },
+      'ftp-ssl-error': {
+        name: 'Falha no handshake seguro de transferência de arquivos',
+        cause: 'O handshake TLS com o servidor de transferência de arquivos da impressora falhou. Geralmente é causado por um firewall ou firmware desatualizado da impressora.',
+        fix: 'Atualize o firmware da impressora e verifique se nenhum firewall ou proxy intercepta a conexão na porta 990.',
+      },
+      'mqtt-connection-flapping': {
+        name: 'A conexão com a impressora cai repetidamente',
+        cause: 'A conexão de controle (MQTT 8883) desconecta e reconecta repetidamente — geralmente por uma rede fraca ou uma porta parcialmente bloqueada.',
+        fix: 'Verifique o sinal de Wi-Fi perto da impressora, prefira uma conexão com fio e certifique-se de que a porta 8883 esteja acessível de forma confiável.',
+      },
+      'camera-connection-refused': {
+        name: 'Transmissão da câmera inacessível',
+        cause: 'Não foi possível acessar a câmera ao vivo na porta RTSPS 322. A porta está bloqueada, ou a câmera ou a visualização ao vivo via LAN está desativada na impressora.',
+        fix: 'Ative a câmera e a visualização ao vivo via LAN na impressora e verifique se a porta 322 não está bloqueada. Isso não afeta a impressão.',
+      },
+      'database-locked': {
+        name: 'Conflitos de gravação no banco de dados',
+        cause: 'O banco de dados SQLite apresenta erros "database is locked" sob carga — comum ao usar várias impressoras ao mesmo tempo.',
+        fix: 'Migre o Bambuddy para um banco de dados PostgreSQL externo. Consulte o guia do PostgreSQL na documentação.',
+      },
+    },
+  },
+
   vpDiagnostic: {
     title: 'Verificação de configuração — {{name}}',
     runButton: 'Executar verificação de configuração',
@@ -5577,6 +5631,8 @@ export default {
   },
 
   bugReport: {
+    logHealthSummary: 'Problemas conhecidos encontrados nos seus logs',
+    logHealthIntro: 'Os logs recentes correspondem a problemas conhecidos. Veja as correções abaixo — resolvê-las pode solucionar seu problema sem um relatório de bug. Você ainda pode enviar um relatório abaixo.',
     title: 'Reportar um bug',
     description: 'Descrição',
     descriptionPlaceholder: 'O que deu errado? Por favor, descreva o problema...',

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

@@ -124,6 +124,12 @@ export default {
 
   // Printers page
   printers: {
+    addPreflight: {
+      checking: '正在检查连接...',
+      warning: '部分连接检查未通过。此打印机可能显示为离线。请查看下方的检查项,尽量修复,或仍然保存。',
+      back: '返回',
+      saveAnyway: '仍然保存',
+    },
     title: '打印机',
     addPrinter: '添加打印机',
     editPrinter: '编辑打印机',
@@ -5522,6 +5528,54 @@ export default {
     },
   },
 
+  systemHealth: {
+    sectionTitle: '系统健康',
+    sectionDescription: '扫描最近的日志以发现已知问题,在它们演变成支持工单之前,通常你可以自行解决。',
+    rescan: '重新扫描',
+    clean: '在最近 {{times}} 条日志中未发现已知问题。',
+    logUnavailable: '文件日志记录已禁用,因此无法扫描日志。请启用文件日志以使用此检查。',
+    learnMore: '如何解决',
+    fixLabel: '解决方法:',
+    occurrences: '出现 {{times}} 次 — 最近一次 {{lastSeen}}',
+    category: {
+      layer8: '你可以自行解决',
+      environment: '环境',
+      bug: '请反馈此问题',
+    },
+    signature: {
+      'ftp-auth-rejected': {
+        name: '打印机拒绝了访问码',
+        cause: '打印机拒绝了文件传输登录。访问码错误,或在切换开发者模式后已更改。',
+        fix: '从打印机屏幕(局域网设置)重新复制访问码,并在 Bambuddy 的打印机设置中更新它。',
+      },
+      'ftp-connection-timeout': {
+        name: '文件传输连接超时',
+        cause: 'Bambuddy 无法连接打印机的文件传输端口(FTPS 990)。该端口被阻止,或打印机已关机或位于其他子网。',
+        fix: '请确保 Bambuddy 与打印机之间的 990 端口未被阻止,并且两者位于同一网络。',
+      },
+      'ftp-ssl-error': {
+        name: '安全文件传输握手失败',
+        cause: '与打印机文件传输服务器的 TLS 握手失败。通常是防火墙或打印机固件过旧所致。',
+        fix: '请更新打印机固件,并检查没有防火墙或代理拦截 990 端口上的连接。',
+      },
+      'mqtt-connection-flapping': {
+        name: '打印机连接反复断开',
+        cause: '控制连接(MQTT 8883)反复断开并重连——通常是网络信号弱或端口被部分阻止所致。',
+        fix: '请检查打印机附近的 Wi-Fi 信号,尽量使用有线连接,并确保 8883 端口能够稳定连接。',
+      },
+      'camera-connection-refused': {
+        name: '无法访问摄像头视频流',
+        cause: '无法在 RTSPS 322 端口连接实时摄像头。该端口被阻止,或打印机上的摄像头或局域网实时画面已关闭。',
+        fix: '请在打印机上启用摄像头和局域网实时画面,并确保 322 端口未被阻止。这不会影响打印。',
+      },
+      'database-locked': {
+        name: '数据库写入冲突',
+        cause: '在负载下 SQLite 数据库出现 "database is locked" 错误——同时使用多台打印机时较为常见。',
+        fix: '请将 Bambuddy 切换到外部 PostgreSQL 数据库。参见文档中的 PostgreSQL 指南。',
+      },
+    },
+  },
+
   vpDiagnostic: {
     title: '设置检查 — {{name}}',
     runButton: '运行设置检查',
@@ -5576,6 +5630,8 @@ export default {
   },
 
   bugReport: {
+    logHealthSummary: '在你的日志中发现了已知问题',
+    logHealthIntro: '最近的日志与已知问题相符。请查看下方的解决方法——解决它们可能无需提交错误报告即可解决你的问题。你仍然可以在下方提交报告。',
     title: '报告错误',
     description: '描述',
     descriptionPlaceholder: '出了什么问题?请描述问题...',

+ 56 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -124,6 +124,12 @@ export default {
 
   // Printers page
   printers: {
+    addPreflight: {
+      checking: '正在檢查連線...',
+      warning: '部分連線檢查未通過。此印表機可能顯示為離線。請查看下方的檢查項目,盡量修復,或仍然儲存。',
+      back: '返回',
+      saveAnyway: '仍然儲存',
+    },
     title: '印表機',
     addPrinter: '新增印表機',
     editPrinter: '編輯印表機',
@@ -5522,6 +5528,54 @@ export default {
     },
   },
 
+  systemHealth: {
+    sectionTitle: '系統健康',
+    sectionDescription: '掃描最近的日誌以找出已知問題,在它們演變成支援工單之前,通常你可以自行解決。',
+    rescan: '重新掃描',
+    clean: '在最近 {{times}} 筆日誌中未發現已知問題。',
+    logUnavailable: '檔案日誌記錄已停用,因此無法掃描日誌。請啟用檔案日誌以使用此檢查。',
+    learnMore: '如何解決',
+    fixLabel: '解決方法:',
+    occurrences: '出現 {{times}} 次 — 最近一次 {{lastSeen}}',
+    category: {
+      layer8: '你可以自行解決',
+      environment: '環境',
+      bug: '請回報此問題',
+    },
+    signature: {
+      'ftp-auth-rejected': {
+        name: '印表機拒絕了存取碼',
+        cause: '印表機拒絕了檔案傳輸登入。存取碼錯誤,或在切換開發者模式後已變更。',
+        fix: '從印表機螢幕(區域網路設定)重新複製存取碼,並在 Bambuddy 的印表機設定中更新。',
+      },
+      'ftp-connection-timeout': {
+        name: '檔案傳輸連線逾時',
+        cause: 'Bambuddy 無法連線印表機的檔案傳輸連接埠(FTPS 990)。該連接埠被封鎖,或印表機已關機或位於其他子網路。',
+        fix: '請確認 Bambuddy 與印表機之間的 990 連接埠未被封鎖,且兩者位於同一網路。',
+      },
+      'ftp-ssl-error': {
+        name: '安全檔案傳輸交握失敗',
+        cause: '與印表機檔案傳輸伺服器的 TLS 交握失敗。通常是防火牆或印表機韌體過舊所致。',
+        fix: '請更新印表機韌體,並檢查沒有防火牆或代理伺服器攔截 990 連接埠上的連線。',
+      },
+      'mqtt-connection-flapping': {
+        name: '印表機連線反覆中斷',
+        cause: '控制連線(MQTT 8883)反覆中斷並重新連線——通常是網路訊號弱或連接埠被部分封鎖所致。',
+        fix: '請檢查印表機附近的 Wi-Fi 訊號,盡量使用有線連線,並確認 8883 連接埠能夠穩定連線。',
+      },
+      'camera-connection-refused': {
+        name: '無法存取攝影機串流',
+        cause: '無法在 RTSPS 322 連接埠連線即時攝影機。該連接埠被封鎖,或印表機上的攝影機或區域網路即時影像已關閉。',
+        fix: '請在印表機上啟用攝影機和區域網路即時影像,並確認 322 連接埠未被封鎖。這不會影響列印。',
+      },
+      'database-locked': {
+        name: '資料庫寫入衝突',
+        cause: '在負載下 SQLite 資料庫出現 "database is locked" 錯誤——同時使用多台印表機時較為常見。',
+        fix: '請將 Bambuddy 切換到外部 PostgreSQL 資料庫。參見文件中的 PostgreSQL 指南。',
+      },
+    },
+  },
+
   vpDiagnostic: {
     title: '設定檢查 — {{name}}',
     runButton: '執行設定檢查',
@@ -5576,6 +5630,8 @@ export default {
   },
 
   bugReport: {
+    logHealthSummary: '在你的日誌中發現了已知問題',
+    logHealthIntro: '最近的日誌與已知問題相符。請查看下方的解決方法——解決它們可能無需提交錯誤報告即可解決你的問題。你仍然可以在下方提交報告。',
     title: '報告錯誤',
     description: '描述',
     descriptionPlaceholder: '出了什麼問題?請描述問題...',

+ 133 - 27
frontend/src/pages/PrintersPage.tsx

@@ -75,7 +75,7 @@ import {
 import { useNavigate } from 'react-router-dom';
 import { api, discoveryApi, firmwareApi, withStreamToken, ApiError } from '../api/client';
 import { formatDateOnly, formatETA, formatDuration, parseUTCDate } from '../utils/date';
-import type { Printer, PrinterCreate, PrinterStatus, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo, SpoolAssignment, HMSError, InventorySpool, SmartPlug } from '../api/client';
+import type { Printer, PrinterCreate, PrinterStatus, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo, SpoolAssignment, HMSError, InventorySpool, SmartPlug, PrinterDiagnosticResult } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
@@ -101,7 +101,7 @@ import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoo
 import { getPrinterImage, getWifiStrength, filterCompatibleQueueItems } from '../utils/printer';
 import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
 import { Collapsible } from '../components/Collapsible';
-import { ConnectionDiagnosticModal } from '../components/ConnectionDiagnostic';
+import { ConnectionDiagnosticModal, DiagnosticChecklist } from '../components/ConnectionDiagnostic';
 import { getColorName, parseFilamentColor, isLightColor } from '../utils/colors';
 
 export interface SpoolmanSlotAssignmentRow {
@@ -5595,6 +5595,13 @@ function AddPrinterModal({
   const [scanProgress, setScanProgress] = useState({ scanned: 0, total: 0 });
   const [showDiagnostic, setShowDiagnostic] = useState(false);
 
+  // Setup-time pre-flight: run the connection diagnostic on save and warn
+  // (not block) when checks fail, so the user doesn't add a printer that
+  // immediately shows offline. checkingSave = probe in flight; saveWarning =
+  // failed result awaiting an explicit "save anyway".
+  const [checkingSave, setCheckingSave] = useState(false);
+  const [saveWarning, setSaveWarning] = useState<PrinterDiagnosticResult | null>(null);
+
   // Fetch discovery info on mount
   useEffect(() => {
     discoveryApi.getInfo().then(info => {
@@ -5611,6 +5618,27 @@ function AddPrinterModal({
   // Filter out already-added printers
   const newPrinters = discovered.filter(p => !existingSerials.includes(p.serial));
 
+  const handleAddSubmit = async (e: React.FormEvent) => {
+    e.preventDefault();
+    setCheckingSave(true);
+    try {
+      const result = await api.diagnoseConnection({
+        ip_address: form.ip_address.trim(),
+        serial_number: form.serial_number.trim() || undefined,
+        access_code: form.access_code || undefined,
+      });
+      if (result.checks.some((c) => c.status === 'fail')) {
+        setSaveWarning(result);
+        return;
+      }
+    } catch {
+      // Diagnostic infrastructure failed — never block the save on it.
+    } finally {
+      setCheckingSave(false);
+    }
+    onAdd(form);
+  };
+
   const startDiscovery = async () => {
     setDiscoveryError('');
     setDiscovered([]);
@@ -5828,13 +5856,7 @@ function AddPrinterModal({
               </p>
             )}
           </div>
-          <form
-            onSubmit={(e) => {
-              e.preventDefault();
-              onAdd(form);
-            }}
-            className="space-y-4"
-          >
+          <form onSubmit={handleAddSubmit} className="space-y-4">
             <div>
               <label className="block text-sm text-bambu-gray mb-1">{t('printers.name')}</label>
               <input
@@ -5945,14 +5967,37 @@ function AddPrinterModal({
               <Stethoscope className="w-4 h-4" />
               {t('diagnostic.runButton')}
             </button>
-            <div className="flex gap-3 pt-2">
-              <Button type="button" variant="secondary" onClick={onClose} className="flex-1">
-                {t('common.cancel')}
-              </Button>
-              <Button type="submit" className="flex-1">
-                {t('printers.addPrinter')}
-              </Button>
-            </div>
+            {saveWarning ? (
+              <div className="rounded-lg bg-amber-500/10 border border-amber-500/30 p-3 space-y-3">
+                <div className="flex items-start gap-2">
+                  <AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0 text-amber-400" />
+                  <p className="text-sm text-amber-300">{t('printers.addPreflight.warning')}</p>
+                </div>
+                <DiagnosticChecklist result={saveWarning} />
+                <div className="flex gap-3">
+                  <Button
+                    type="button"
+                    variant="secondary"
+                    onClick={() => setSaveWarning(null)}
+                    className="flex-1"
+                  >
+                    {t('printers.addPreflight.back')}
+                  </Button>
+                  <Button type="button" onClick={() => onAdd(form)} className="flex-1">
+                    {t('printers.addPreflight.saveAnyway')}
+                  </Button>
+                </div>
+              </div>
+            ) : (
+              <div className="flex gap-3 pt-2">
+                <Button type="button" variant="secondary" onClick={onClose} className="flex-1">
+                  {t('common.cancel')}
+                </Button>
+                <Button type="submit" disabled={checkingSave} className="flex-1">
+                  {checkingSave ? t('printers.addPreflight.checking') : t('printers.addPrinter')}
+                </Button>
+              </div>
+            )}
           </form>
         </CardContent>
       </Card>
@@ -6271,6 +6316,11 @@ function EditPrinterModal({
     auto_archive: printer.auto_archive,
   });
 
+  // Setup-time pre-flight — same warn-on-save as the Add-Printer dialog, so an
+  // edit that breaks connectivity (e.g. a mistyped IP) is caught before save.
+  const [checkingSave, setCheckingSave] = useState(false);
+  const [saveWarning, setSaveWarning] = useState<PrinterDiagnosticResult | null>(null);
+
   const updateMutation = useMutation({
     mutationFn: (data: Partial<PrinterCreate>) => api.updatePrinter(printer.id, data),
     onSuccess: () => {
@@ -6290,8 +6340,7 @@ function EditPrinterModal({
     return () => window.removeEventListener('keydown', handleKeyDown);
   }, [onClose]);
 
-  const handleSubmit = (e: React.FormEvent) => {
-    e.preventDefault();
+  const doSave = () => {
     const data: Partial<PrinterCreate> = {
       name: form.name,
       ip_address: form.ip_address,
@@ -6306,6 +6355,27 @@ function EditPrinterModal({
     updateMutation.mutate(data);
   };
 
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault();
+    setCheckingSave(true);
+    try {
+      const result = await api.diagnoseConnection({
+        ip_address: form.ip_address.trim(),
+        serial_number: printer.serial_number,
+        access_code: form.access_code || undefined,
+      });
+      if (result.checks.some((c) => c.status === 'fail')) {
+        setSaveWarning(result);
+        return;
+      }
+    } catch {
+      // Diagnostic infrastructure failed — never block the save on it.
+    } finally {
+      setCheckingSave(false);
+    }
+    doSave();
+  };
+
   return (
     <div
       className="fixed inset-0 bg-black/50 flex items-start sm:items-center justify-center z-50 p-4 overflow-y-auto"
@@ -6414,14 +6484,50 @@ function EditPrinterModal({
                 {t('printers.modal.autoArchiveLabel')}
               </label>
             </div>
-            <div className="flex gap-3 pt-4">
-              <Button type="button" variant="secondary" onClick={onClose} className="flex-1">
-                {t('common.cancel')}
-              </Button>
-              <Button type="submit" className="flex-1" disabled={updateMutation.isPending}>
-                {updateMutation.isPending ? t('common.saving') : t('printers.modal.saveChanges')}
-              </Button>
-            </div>
+            {saveWarning ? (
+              <div className="rounded-lg bg-amber-500/10 border border-amber-500/30 p-3 space-y-3">
+                <div className="flex items-start gap-2">
+                  <AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0 text-amber-400" />
+                  <p className="text-sm text-amber-300">{t('printers.addPreflight.warning')}</p>
+                </div>
+                <DiagnosticChecklist result={saveWarning} />
+                <div className="flex gap-3">
+                  <Button
+                    type="button"
+                    variant="secondary"
+                    onClick={() => setSaveWarning(null)}
+                    className="flex-1"
+                  >
+                    {t('printers.addPreflight.back')}
+                  </Button>
+                  <Button
+                    type="button"
+                    onClick={doSave}
+                    className="flex-1"
+                    disabled={updateMutation.isPending}
+                  >
+                    {t('printers.addPreflight.saveAnyway')}
+                  </Button>
+                </div>
+              </div>
+            ) : (
+              <div className="flex gap-3 pt-4">
+                <Button type="button" variant="secondary" onClick={onClose} className="flex-1">
+                  {t('common.cancel')}
+                </Button>
+                <Button
+                  type="submit"
+                  className="flex-1"
+                  disabled={updateMutation.isPending || checkingSave}
+                >
+                  {checkingSave
+                    ? t('printers.addPreflight.checking')
+                    : updateMutation.isPending
+                      ? t('common.saving')
+                      : t('printers.modal.saveChanges')}
+                </Button>
+              </div>
+            )}
           </form>
         </CardContent>
       </Card>

+ 32 - 0
frontend/src/pages/SystemInfoPage.tsx

@@ -22,11 +22,13 @@ import {
   Headphones,
   FolderOpen,
   Stethoscope,
+  HeartPulse,
 } from 'lucide-react';
 import { api, supportApi, type Printer as PrinterModel } from '../api/client';
 import { Card } from '../components/Card';
 import { LogViewer } from '../components/LogViewer';
 import { ConnectionDiagnosticModal } from '../components/ConnectionDiagnostic';
+import { SystemHealthPanel } from '../components/SystemHealthPanel';
 import { formatDateTime, type TimeFormat } from '../utils/date';
 
 function formatBytes(bytes: number): string {
@@ -130,6 +132,16 @@ export function SystemInfoPage() {
     queryFn: api.getPrinters,
   });
 
+  const {
+    data: systemHealth,
+    refetch: refetchHealth,
+    isFetching: healthFetching,
+  } = useQuery({
+    queryKey: ['systemHealth'],
+    queryFn: api.getSystemHealth,
+    staleTime: 60 * 1000,
+  });
+
   const timeFormat: TimeFormat = settings?.time_format || 'system';
 
   const handleToggleDebugLogging = async () => {
@@ -397,6 +409,26 @@ export function SystemInfoPage() {
         )}
       </Section>
 
+      {/* System Health */}
+      <Section title={t('systemHealth.sectionTitle')} icon={HeartPulse}>
+        <div className="flex items-start justify-between gap-3 mb-3">
+          <p className="text-sm text-bambu-gray">{t('systemHealth.sectionDescription')}</p>
+          <button
+            onClick={() => refetchHealth()}
+            disabled={healthFetching}
+            className="flex items-center gap-2 px-3 py-1.5 text-sm bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary text-white rounded-lg transition-colors flex-shrink-0 disabled:opacity-50"
+          >
+            <RefreshCw className={`w-4 h-4 ${healthFetching ? 'animate-spin' : ''}`} />
+            {t('systemHealth.rescan')}
+          </button>
+        </div>
+        {systemHealth ? (
+          <SystemHealthPanel result={systemHealth} />
+        ) : (
+          <p className="text-sm text-bambu-gray">{t('common.loading')}</p>
+        )}
+      </Section>
+
       {/* Database Stats */}
       <Section title={t('system.database', 'Database')} icon={Database}>
         <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/assets/index-BL4kzp1A.js


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/assets/index-BzucE4G0.css


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/assets/index-DYdMf_Qm.css


+ 2 - 2
static/index.html

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

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác