Browse Source

feat: connection diagnostic for "printer won't connect" triage

  A triage review of the last 200 closed issues found ~1/3 were
  user-side setup errors — printer not in LAN developer mode, blocked
  ports, Docker bridge networking, wrong access code, cross-subnet —
  each costing a multi-round-trip support exchange.

  Add a Connection Diagnostic that runs those checks automatically:
  - backend/app/services/printer_diagnostic.py: TCP probes of MQTT
    8883 / FTPS 990 / RTSPS 322, LAN developer mode, Docker network
    mode, printer/host subnet match, MQTT credential class; each
    check returns pass/fail/warn/skip with a localized fix.
  - Routes: GET /printers/{id}/diagnostic (saved printer) and
    POST /printers/diagnostic (pre-save Add-Printer flow).
  - ConnectionDiagnostic.tsx: modal + shared checklist, surfaced from
    the printer card actions menu, an offline-printer quick button,
    the Add-Printer dialog, and a new System-page section.
  - The in-app bug reporter scans configured printers when the form
    opens and always shows the result inline — a healthy confirmation,
    or the detected problem and its fix.
  - config.yml troubleshooting link repointed to the rendered wiki
    page; bug_report.yml gains a diagnostic checkbox.

  Diagnostic strings translated across all 8 locales. Backend service
  unit tests (15) + frontend modal tests (3). Ruff clean, frontend
  build clean, i18n parity green.
maziggy 6 days ago
parent
commit
76e327f4a1

+ 2 - 0
.github/ISSUE_TEMPLATE/bug_report.yml

@@ -172,3 +172,5 @@ body:
           required: true
           required: true
         - label: My printer has Developer Mode enabled
         - label: My printer has Developer Mode enabled
           required: true
           required: true
+        - label: For a connection or printing problem, I ran the in-app Connection Diagnostic (printer card or System page) and included the result above
+          required: false

+ 6 - 3
.github/ISSUE_TEMPLATE/config.yml

@@ -1,8 +1,11 @@
 blank_issues_enabled: false
 blank_issues_enabled: false
 contact_links:
 contact_links:
+  - name: Printer won't connect or won't print?
+    url: https://wiki.bambuddy.cool/reference/troubleshooting/
+    about: Most connection and printing problems are setup issues. Check the troubleshooting guide and run the in-app Connection Diagnostic (printer card or System page) before opening an issue.
   - name: Documentation
   - name: Documentation
-    url: https://github.com/maziggy/bambuddy-wiki
-    about: Check the documentation for guides and troubleshooting
+    url: https://wiki.bambuddy.cool/
+    about: Setup guides, feature documentation, and reference.
   - name: Community Forum
   - name: Community Forum
     url: https://forum.bambuddy.cool
     url: https://forum.bambuddy.cool
-    about: Ask questions and share ideas with the community
+    about: Ask questions and share ideas with the community.

File diff suppressed because it is too large
+ 1 - 0
CHANGELOG.md


+ 34 - 0
backend/app/api/routes/printers.py

@@ -19,11 +19,13 @@ from backend.app.schemas.printer import (
     AmsLabelBody,
     AmsLabelBody,
     AMSTray,
     AMSTray,
     AMSUnit,
     AMSUnit,
+    DiagnosticRequest,
     FilaSwitchResponse,
     FilaSwitchResponse,
     HMSErrorResponse,
     HMSErrorResponse,
     NozzleInfoResponse,
     NozzleInfoResponse,
     NozzleRackSlot,
     NozzleRackSlot,
     PrinterCreate,
     PrinterCreate,
+    PrinterDiagnosticResult,
     PrinterResponse,
     PrinterResponse,
     PrinterStatus,
     PrinterStatus,
     PrinterUpdate,
     PrinterUpdate,
@@ -38,6 +40,7 @@ from backend.app.services.bambu_ftp import (
     get_storage_info_async,
     get_storage_info_async,
     list_files_async,
     list_files_async,
 )
 )
+from backend.app.services.printer_diagnostic import run_connection_diagnostic
 from backend.app.services.printer_manager import (
 from backend.app.services.printer_manager import (
     get_derived_status_name,
     get_derived_status_name,
     printer_manager,
     printer_manager,
@@ -770,6 +773,37 @@ async def test_printer_connection(
     return result
     return result
 
 
 
 
+@router.post("/diagnostic", response_model=PrinterDiagnosticResult)
+async def diagnose_connection(
+    req: DiagnosticRequest,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE),
+):
+    """Run connection diagnostics for the Add-Printer flow (printer not yet saved).
+
+    When serial_number + access_code are supplied the MQTT credential check
+    also runs; otherwise only the network-level checks are performed.
+    """
+    return await run_connection_diagnostic(
+        req.ip_address,
+        serial_number=req.serial_number or None,
+        access_code=req.access_code or None,
+    )
+
+
+@router.get("/{printer_id}/diagnostic", response_model=PrinterDiagnosticResult)
+async def diagnose_printer(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Run connection diagnostics for an existing saved printer."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+    return await run_connection_diagnostic(printer.ip_address, printer=printer)
+
+
 # Cache for cover images (printer_id -> {(subtask_name, view_key) -> image_bytes}).
 # Cache for cover images (printer_id -> {(subtask_name, view_key) -> image_bytes}).
 # Cleared on every print start by main.py::on_print_start, so re-dispatches with
 # Cleared on every print start by main.py::on_print_start, so re-dispatches with
 # different plates always fetch a fresh thumbnail without needing plate in the key.
 # different plates always fetch a fresh thumbnail without needing plate in the key.

+ 36 - 0
backend/app/schemas/printer.py

@@ -324,3 +324,39 @@ class PrinterStatus(BaseModel):
     # Set for every active print regardless of plate count; the frontend decides
     # Set for every active print regardless of plate count; the frontend decides
     # whether to render it based on current_archive_id's is_multi_plate flag.
     # whether to render it based on current_archive_id's is_multi_plate flag.
     current_plate_id: int | None = None
     current_plate_id: int | None = None
+
+
+class DiagnosticCheck(BaseModel):
+    """One connection-diagnostic check result.
+
+    ``id`` is a stable key (port_mqtt, port_ftps, port_rtsps, network_mode,
+    subnet, mqtt_auth, developer_mode); the frontend renders the localized
+    title and fix text from id + status. ``params`` carries interpolation
+    values (e.g. network mode, IP addresses) for that text.
+    """
+
+    id: str
+    status: str  # "pass" | "fail" | "warn" | "skip"
+    params: dict = Field(default_factory=dict)
+
+
+class PrinterDiagnosticResult(BaseModel):
+    """Result of a printer connection diagnostic run."""
+
+    printer_id: int | None = None
+    ip_address: str
+    overall: str  # "ok" | "warnings" | "problems"
+    checks: list[DiagnosticCheck]
+
+
+class DiagnosticRequest(BaseModel):
+    """Pre-save (Add Printer) connection diagnostic request.
+
+    serial_number + access_code are optional: when both are present the
+    diagnostic also probes MQTT credentials, otherwise only the
+    network-level checks run.
+    """
+
+    ip_address: str
+    serial_number: str | None = None
+    access_code: str | None = None

+ 201 - 0
backend/app/services/printer_diagnostic.py

@@ -0,0 +1,201 @@
+"""Connection diagnostic for Bambu printers.
+
+Runs the checks a maintainer performs by hand when triaging a
+"printer won't connect / won't print" report — port reachability, LAN
+developer mode, Docker network mode, subnet match, and MQTT credentials —
+so users can self-diagnose setup problems instead of opening an issue.
+
+See the 2026-05-21 issue-triage analysis: ~1/3 of closed issues were
+user-side setup errors clustered on exactly these causes.
+"""
+
+import asyncio
+import ipaddress
+import logging
+import socket
+
+from backend.app.models.printer import Printer
+from backend.app.schemas.printer import DiagnosticCheck, PrinterDiagnosticResult
+from backend.app.services.discovery import is_running_in_docker
+from backend.app.services.printer_manager import printer_manager
+
+logger = logging.getLogger(__name__)
+
+# Bambu LAN-mode ports.
+PORT_MQTT = 8883  # MQTT over TLS — control + status. Connection-critical.
+PORT_FTPS = 990  # FTPS — file upload; required to send prints.
+PORT_RTSPS = 322  # RTSPS — camera stream; optional.
+
+_PORT_PROBE_TIMEOUT = 3.0
+
+
+async def _check_port(ip: str, port: int, timeout: float = _PORT_PROBE_TIMEOUT) -> bool:
+    """Test TCP connectivity to ip:port. Returns True if reachable."""
+    try:
+        _reader, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)
+        writer.close()
+        try:
+            await writer.wait_closed()
+        except Exception:
+            pass
+        return True
+    except Exception:
+        return False
+
+
+def _detect_docker_network_mode() -> str:
+    """Detect Docker network mode.
+
+    In host mode the container shares the host network namespace, so Docker
+    infrastructure interfaces (docker0, br-*, veth*) are visible. In bridge
+    mode the container only sees its own eth0.
+    """
+    try:
+        for _idx, name in socket.if_nameindex():
+            if name.startswith(("docker", "br-", "veth", "virbr")):
+                return "host"
+    except Exception:
+        pass
+    return "bridge"
+
+
+def _get_host_ip() -> str | None:
+    """Best-effort IPv4 address the Bambuddy host routes from."""
+    try:
+        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+        try:
+            # No packets are sent; this just picks the routing-table source IP.
+            s.connect(("10.255.255.255", 1))
+            return s.getsockname()[0]
+        finally:
+            s.close()
+    except Exception:
+        return None
+
+
+def _same_subnet(ip_a: str, ip_b: str) -> bool | None:
+    """True/False if both are IPv4 literals in the same /24; None if undeterminable."""
+    try:
+        addr_a = ipaddress.ip_address(ip_a)
+        addr_b = ipaddress.ip_address(ip_b)
+    except ValueError:
+        return None
+    if addr_a.version != 4 or addr_b.version != 4:
+        return None
+    net_a = ipaddress.ip_network(f"{addr_a}/24", strict=False)
+    net_b = ipaddress.ip_network(f"{addr_b}/24", strict=False)
+    return net_a == net_b
+
+
+async def run_connection_diagnostic(
+    ip_address: str,
+    *,
+    printer: Printer | None = None,
+    serial_number: str | None = None,
+    access_code: str | None = None,
+) -> PrinterDiagnosticResult:
+    """Run connection checks for a printer.
+
+    Works for an existing saved printer (pass ``printer``) and for the
+    pre-save Add-Printer flow (pass ``serial_number`` + ``access_code``).
+
+    Each check carries a stable ``id`` and a ``status`` of
+    pass / fail / warn / skip; the frontend renders the human-readable
+    title and fix text (localized) keyed on that id + status.
+    """
+    checks: list[DiagnosticCheck] = []
+
+    # --- Port reachability (probed in parallel) ---
+    mqtt_ok, ftps_ok, rtsps_ok = await asyncio.gather(
+        _check_port(ip_address, PORT_MQTT),
+        _check_port(ip_address, PORT_FTPS),
+        _check_port(ip_address, PORT_RTSPS),
+    )
+    # MQTT is connection-critical; FTPS/RTSPS only degrade printing/camera.
+    checks.append(DiagnosticCheck(id="port_mqtt", status="pass" if mqtt_ok else "fail"))
+    checks.append(DiagnosticCheck(id="port_ftps", status="pass" if ftps_ok else "warn"))
+    checks.append(DiagnosticCheck(id="port_rtsps", status="pass" if rtsps_ok else "warn"))
+
+    # --- Docker network mode ---
+    network_mode: str | None = None
+    if is_running_in_docker():
+        network_mode = _detect_docker_network_mode()
+        checks.append(
+            DiagnosticCheck(
+                id="network_mode",
+                status="pass" if network_mode == "host" else "warn",
+                params={"mode": network_mode},
+            )
+        )
+    else:
+        checks.append(DiagnosticCheck(id="network_mode", status="skip"))
+
+    # --- Subnet match ---
+    # Skipped in bridge mode: the container IP is the bridge IP, not the host's,
+    # so the comparison is meaningless and the network_mode check already covers it.
+    if network_mode == "bridge":
+        checks.append(DiagnosticCheck(id="subnet", status="skip"))
+    else:
+        host_ip = _get_host_ip()
+        same = _same_subnet(ip_address, host_ip) if host_ip else None
+        if same is None:
+            checks.append(DiagnosticCheck(id="subnet", status="skip"))
+        else:
+            checks.append(
+                DiagnosticCheck(
+                    id="subnet",
+                    status="pass" if same else "warn",
+                    params={"printer_ip": ip_address, "host_ip": host_ip},
+                )
+            )
+
+    # --- MQTT credentials / connection ---
+    state = printer_manager.get_status(printer.id) if printer else None
+    if not mqtt_ok:
+        # Can't reach the broker at all — the port check already reported it.
+        checks.append(DiagnosticCheck(id="mqtt_auth", status="skip"))
+    elif serial_number and access_code:
+        # Pre-add flow: actively probe with the credentials the user entered.
+        try:
+            result = await printer_manager.test_connection(
+                ip_address=ip_address,
+                serial_number=serial_number,
+                access_code=access_code,
+            )
+            checks.append(DiagnosticCheck(id="mqtt_auth", status="pass" if result.get("success") else "fail"))
+        except Exception:
+            logger.debug("test_connection failed during diagnostic", exc_info=True)
+            checks.append(DiagnosticCheck(id="mqtt_auth", status="fail"))
+    elif state is not None:
+        # Existing printer: trust the live MQTT state rather than opening a
+        # second connection (Bambu printers tolerate few concurrent sessions).
+        checks.append(DiagnosticCheck(id="mqtt_auth", status="pass" if state.connected else "fail"))
+    else:
+        checks.append(DiagnosticCheck(id="mqtt_auth", status="skip"))
+
+    # --- LAN developer mode (only readable over a live MQTT connection) ---
+    if state is not None and state.connected:
+        if state.developer_mode is True:
+            dev_status = "pass"
+        elif state.developer_mode is False:
+            dev_status = "fail"
+        else:
+            dev_status = "skip"
+        checks.append(DiagnosticCheck(id="developer_mode", status=dev_status))
+    else:
+        checks.append(DiagnosticCheck(id="developer_mode", status="skip"))
+
+    statuses = {c.status for c in checks}
+    if "fail" in statuses:
+        overall = "problems"
+    elif "warn" in statuses:
+        overall = "warnings"
+    else:
+        overall = "ok"
+
+    return PrinterDiagnosticResult(
+        printer_id=printer.id if printer else None,
+        ip_address=ip_address,
+        overall=overall,
+        checks=checks,
+    )

+ 178 - 0
backend/tests/unit/services/test_printer_diagnostic.py

@@ -0,0 +1,178 @@
+"""Unit tests for the connection diagnostic.
+
+Pins the pass / fail / warn / skip contract of each check. Those statuses
+drive the localized fix text the user sees when a printer won't connect,
+so a status flip is a user-facing regression — each one is asserted here.
+"""
+
+import types
+from contextlib import ExitStack
+from unittest.mock import AsyncMock, MagicMock, patch
+
+from backend.app.services.printer_diagnostic import _same_subnet, run_connection_diagnostic
+
+MOD = "backend.app.services.printer_diagnostic"
+
+
+def _statuses(result):
+    """Map of check id -> status for concise assertions."""
+    return {c.id: c.status for c in result.checks}
+
+
+def _port_probe(overrides=None):
+    """Sync side_effect for _check_port. Defaults: every port reachable."""
+    reachable = {8883: True, 990: True, 322: True}
+    reachable.update(overrides or {})
+
+    def _probe(ip, port, timeout=3.0):
+        return reachable[port]
+
+    return _probe
+
+
+def _state(*, connected=True, developer_mode=True):
+    return types.SimpleNamespace(connected=connected, developer_mode=developer_mode)
+
+
+class _Env:
+    """Patches the diagnostic's network/printer helpers for one run."""
+
+    def __init__(
+        self,
+        *,
+        ports=None,
+        in_docker=True,
+        network_mode="host",
+        host_ip="192.168.1.5",
+        state=None,
+        test_connection_success=True,
+    ):
+        self.ports = ports or _port_probe()
+        self.in_docker = in_docker
+        self.network_mode = network_mode
+        self.host_ip = host_ip
+        self.state = state
+        self.test_connection_success = test_connection_success
+        self._stack = ExitStack()
+
+    def __enter__(self):
+        manager = MagicMock()
+        manager.get_status.return_value = self.state
+        manager.test_connection = AsyncMock(return_value={"success": self.test_connection_success})
+        self._stack.enter_context(patch(f"{MOD}._check_port", new_callable=AsyncMock, side_effect=self.ports))
+        self._stack.enter_context(patch(f"{MOD}.is_running_in_docker", return_value=self.in_docker))
+        self._stack.enter_context(patch(f"{MOD}._detect_docker_network_mode", return_value=self.network_mode))
+        self._stack.enter_context(patch(f"{MOD}._get_host_ip", return_value=self.host_ip))
+        self._stack.enter_context(patch(f"{MOD}.printer_manager", manager))
+        return self
+
+    def __exit__(self, *exc):
+        self._stack.close()
+        return False
+
+
+def _printer(ip="192.168.1.50"):
+    return types.SimpleNamespace(id=1, ip_address=ip)
+
+
+class TestSameSubnet:
+    def test_same_24(self):
+        assert _same_subnet("192.168.1.10", "192.168.1.200") is True
+
+    def test_different_24(self):
+        assert _same_subnet("192.168.1.10", "192.168.2.10") is False
+
+    def test_hostname_undeterminable(self):
+        assert _same_subnet("printer.local", "192.168.1.10") is None
+
+    def test_ipv6_undeterminable(self):
+        assert _same_subnet("fe80::1", "192.168.1.10") is None
+
+
+class TestExistingPrinter:
+    async def test_all_healthy(self):
+        with _Env(state=_state(connected=True, developer_mode=True)):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        s = _statuses(result)
+        assert result.overall == "ok"
+        assert s == {
+            "port_mqtt": "pass",
+            "port_ftps": "pass",
+            "port_rtsps": "pass",
+            "network_mode": "pass",
+            "subnet": "pass",
+            "mqtt_auth": "pass",
+            "developer_mode": "pass",
+        }
+
+    async def test_mqtt_port_unreachable_is_a_problem(self):
+        with _Env(ports=_port_probe({8883: False}), state=_state()):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        s = _statuses(result)
+        assert result.overall == "problems"
+        assert s["port_mqtt"] == "fail"
+        # Auth can't be judged when the broker port itself is closed.
+        assert s["mqtt_auth"] == "skip"
+
+    async def test_ftps_and_rtsps_only_warn(self):
+        with _Env(ports=_port_probe({990: False, 322: False}), state=_state()):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        s = _statuses(result)
+        # No critical failure -> warnings, not problems.
+        assert result.overall == "warnings"
+        assert s["port_ftps"] == "warn"
+        assert s["port_rtsps"] == "warn"
+
+    async def test_developer_mode_off_is_a_problem(self):
+        with _Env(state=_state(connected=True, developer_mode=False)):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        s = _statuses(result)
+        assert s["developer_mode"] == "fail"
+        assert result.overall == "problems"
+
+    async def test_developer_mode_skipped_when_disconnected(self):
+        # No live MQTT connection -> developer_mode can't be read.
+        with _Env(state=_state(connected=False)):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        s = _statuses(result)
+        assert s["developer_mode"] == "skip"
+        # Reachable port but no connection -> credential failure class.
+        assert s["mqtt_auth"] == "fail"
+
+    async def test_bridge_mode_warns_and_skips_subnet(self):
+        with _Env(network_mode="bridge", state=_state()):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        s = _statuses(result)
+        assert s["network_mode"] == "warn"
+        # Container IP isn't the host IP in bridge mode -> subnet check is meaningless.
+        assert s["subnet"] == "skip"
+
+    async def test_network_mode_skipped_outside_docker(self):
+        with _Env(in_docker=False, state=_state()):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        assert _statuses(result)["network_mode"] == "skip"
+
+    async def test_different_subnet_warns(self):
+        with _Env(host_ip="10.0.0.5", state=_state()):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        assert _statuses(result)["subnet"] == "warn"
+
+
+class TestPreAddFlow:
+    async def test_bad_credentials_fail_mqtt_auth(self):
+        with _Env(test_connection_success=False):
+            result = await run_connection_diagnostic("192.168.1.50", serial_number="01P", access_code="wrong")
+        s = _statuses(result)
+        assert s["mqtt_auth"] == "fail"
+        # No saved printer -> developer mode can't be read.
+        assert s["developer_mode"] == "skip"
+
+    async def test_good_credentials_pass_mqtt_auth(self):
+        with _Env(test_connection_success=True):
+            result = await run_connection_diagnostic("192.168.1.50", serial_number="01P", access_code="right")
+        assert _statuses(result)["mqtt_auth"] == "pass"
+
+    async def test_no_credentials_skips_mqtt_auth(self):
+        with _Env():
+            result = await run_connection_diagnostic("192.168.1.50")
+        assert _statuses(result)["mqtt_auth"] == "skip"

+ 101 - 0
frontend/src/__tests__/components/ConnectionDiagnosticModal.test.tsx

@@ -0,0 +1,101 @@
+/**
+ * Tests for the connection diagnostic modal.
+ *
+ * Covers the user-facing contract: the modal runs the diagnostic on mount,
+ * renders each check's localized title and fix text keyed on id + status,
+ * picks the right API for printer vs pre-add mode, and re-runs on retry.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, waitFor, fireEvent } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { I18nextProvider } from 'react-i18next';
+import i18n from '../../i18n';
+import { ConnectionDiagnosticModal } from '../../components/ConnectionDiagnostic';
+import { api, type PrinterDiagnosticResult } from '../../api/client';
+
+const PROBLEM_RESULT: PrinterDiagnosticResult = {
+  printer_id: 1,
+  ip_address: '192.168.1.50',
+  overall: 'problems',
+  checks: [
+    { id: 'port_mqtt', status: 'pass', params: {} },
+    { id: 'port_ftps', status: 'pass', params: {} },
+    { id: 'port_rtsps', status: 'warn', params: {} },
+    { id: 'network_mode', status: 'pass', params: { mode: 'host' } },
+    { id: 'subnet', status: 'pass', params: {} },
+    { id: 'mqtt_auth', status: 'pass', params: {} },
+    { id: 'developer_mode', status: 'fail', params: {} },
+  ],
+};
+
+function renderModal(props: Parameters<typeof ConnectionDiagnosticModal>[0]) {
+  const queryClient = new QueryClient({
+    defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+  });
+  render(
+    <QueryClientProvider client={queryClient}>
+      <I18nextProvider i18n={i18n}>
+        <ConnectionDiagnosticModal {...props} />
+      </I18nextProvider>
+    </QueryClientProvider>,
+  );
+}
+
+describe('ConnectionDiagnosticModal', () => {
+  it('runs the diagnostic on mount and renders check titles + the overall banner', async () => {
+    const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue(PROBLEM_RESULT);
+
+    renderModal({ printerId: 1, printerName: 'Test P1S', onClose: vi.fn() });
+
+    await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
+    expect(spy).toHaveBeenCalledWith(1);
+
+    // Each check's localized title renders.
+    expect(await screen.findByText(/Control port \(MQTT 8883\)/i)).toBeInTheDocument();
+    expect(screen.getByText(/LAN Developer Mode/i)).toBeInTheDocument();
+
+    // The failing developer_mode check shows its fix text.
+    expect(screen.getByText(/Developer Mode is OFF/i)).toBeInTheDocument();
+
+    // Overall banner reflects "problems".
+    expect(
+      screen.getByText(/Found problems that explain why the printer/i),
+    ).toBeInTheDocument();
+
+    spy.mockRestore();
+  });
+
+  it('uses the pre-add API when given a connection instead of a printerId', async () => {
+    const spy = vi.spyOn(api, 'diagnoseConnection').mockResolvedValue({
+      ...PROBLEM_RESULT,
+      printer_id: null,
+    });
+
+    renderModal({
+      connection: { ip_address: '192.168.1.99', serial_number: '01P', access_code: 'abc' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
+    expect(spy).toHaveBeenCalledWith({
+      ip_address: '192.168.1.99',
+      serial_number: '01P',
+      access_code: 'abc',
+    });
+
+    spy.mockRestore();
+  });
+
+  it('re-runs the diagnostic when the user clicks Run again', async () => {
+    const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue(PROBLEM_RESULT);
+
+    renderModal({ printerId: 1, printerName: 'Test P1S', onClose: vi.fn() });
+    await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
+
+    fireEvent.click(screen.getByText(/Run again/i));
+    await waitFor(() => expect(spy).toHaveBeenCalledTimes(2));
+
+    spy.mockRestore();
+  });
+});

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

@@ -188,6 +188,31 @@ export interface CameraDiagnoseResult {
   summary_code: string;
   summary_code: string;
 }
 }
 
 
+// Connection diagnostic (GET /printers/{id}/diagnostic and
+// POST /printers/diagnostic). Each check's `id` + `status` resolve a
+// localized title/fix under `diagnostic.check.*`; `params` interpolate it.
+export type DiagnosticStatus = 'pass' | 'fail' | 'warn' | 'skip';
+
+export interface DiagnosticCheck {
+  id:
+    | 'port_mqtt'
+    | 'port_ftps'
+    | 'port_rtsps'
+    | 'network_mode'
+    | 'subnet'
+    | 'mqtt_auth'
+    | 'developer_mode';
+  status: DiagnosticStatus;
+  params: Record<string, string | number>;
+}
+
+export interface PrinterDiagnosticResult {
+  printer_id: number | null;
+  ip_address: string;
+  overall: 'ok' | 'warnings' | 'problems';
+  checks: DiagnosticCheck[];
+}
+
 // Long-lived camera-stream tokens (#1108). The `token` field is populated
 // Long-lived camera-stream tokens (#1108). The `token` field is populated
 // only on the create response — listing endpoints set it to null because
 // only on the create response — listing endpoints set it to null because
 // the plaintext value is shown to the user exactly once.
 // the plaintext value is shown to the user exactly once.
@@ -5013,6 +5038,17 @@ export const api = {
     request<{ active: boolean; stalled: boolean }>(`/printers/${printerId}/camera/status`),
     request<{ active: boolean; stalled: boolean }>(`/printers/${printerId}/camera/status`),
   diagnoseCamera: (printerId: number) =>
   diagnoseCamera: (printerId: number) =>
     request<CameraDiagnoseResult>(`/printers/${printerId}/camera/diagnose`, { method: 'POST' }),
     request<CameraDiagnoseResult>(`/printers/${printerId}/camera/diagnose`, { method: 'POST' }),
+  diagnosePrinter: (printerId: number) =>
+    request<PrinterDiagnosticResult>(`/printers/${printerId}/diagnostic`),
+  diagnoseConnection: (body: {
+    ip_address: string;
+    serial_number?: string;
+    access_code?: string;
+  }) =>
+    request<PrinterDiagnosticResult>('/printers/diagnostic', {
+      method: 'POST',
+      body: JSON.stringify(body),
+    }),
 
 
   // Plate Detection - Multi-reference calibration (stores up to 5 references per printer)
   // Plate Detection - Multi-reference calibration (stores up to 5 references per printer)
   checkPlateEmpty: (printerId: number, options?: { useExternal?: boolean; includeDebugImage?: boolean }) => {
   checkPlateEmpty: (printerId: number, options?: { useExternal?: boolean; includeDebugImage?: boolean }) => {

+ 61 - 2
frontend/src/components/BugReportBubble.tsx

@@ -1,7 +1,9 @@
 import { useState, useRef, useCallback, useEffect } from 'react';
 import { useState, useRef, useCallback, useEffect } from 'react';
-import { Bug, X, Loader2, CheckCircle, AlertCircle, Trash2, Upload, Circle, CheckCircle2 } from 'lucide-react';
+import { Bug, X, Loader2, CheckCircle, AlertCircle, Trash2, Upload, Circle, CheckCircle2, Stethoscope } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { bugReportApi } from '../api/client';
+import { useQuery } from '@tanstack/react-query';
+import { api, bugReportApi, type PrinterDiagnosticResult } from '../api/client';
+import { DiagnosticChecklist } from './ConnectionDiagnostic';
 
 
 type ViewState = 'form' | 'logging' | 'stopping' | 'submitting' | 'success' | 'error';
 type ViewState = 'form' | 'logging' | 'stopping' | 'submitting' | 'success' | 'error';
 
 
@@ -56,6 +58,25 @@ export function BugReportBubble() {
   const fileInputRef = useRef<HTMLInputElement>(null);
   const fileInputRef = useRef<HTMLInputElement>(null);
   const handleStopLoggingRef = useRef<() => void>(() => {});
   const handleStopLoggingRef = useRef<() => void>(() => {});
 
 
+  // Before the user files a report, diagnose configured printers. Most bug
+  // reports are setup issues — surfacing a connection problem inline lets the
+  // user self-fix instead of waiting on a triage round-trip. The result is
+  // always shown (healthy or not) so the user can see the check ran.
+  const diagnosticScan = useQuery({
+    queryKey: ['bugReportDiagnostic'],
+    enabled: isOpen && viewState === 'form',
+    staleTime: 30_000,
+    queryFn: async (): Promise<PrinterDiagnosticResult[]> => {
+      const printers = await api.getPrinters();
+      const results = await Promise.all(
+        printers.map((p) => api.diagnosePrinter(p.id).catch(() => null)),
+      );
+      return results.filter((r): r is PrinterDiagnosticResult => r !== null);
+    },
+  });
+  const diagnosticResults = diagnosticScan.data ?? [];
+  const diagnosticProblems = diagnosticResults.filter((r) => r.overall === 'problems');
+
   // Elapsed timer for logging phase — auto-stop at 5 minutes
   // Elapsed timer for logging phase — auto-stop at 5 minutes
   useEffect(() => {
   useEffect(() => {
     if (viewState !== 'logging') return;
     if (viewState !== 'logging') return;
@@ -211,6 +232,44 @@ export function BugReportBubble() {
             <div className="p-4 space-y-4">
             <div className="p-4 space-y-4">
               {viewState === 'form' && (
               {viewState === 'form' && (
                 <>
                 <>
+                  {/* Connection diagnostic — scanned on form-open. The result
+                      is always shown: a problem panel when a printer has a
+                      detected setup issue, otherwise a healthy confirmation. */}
+                  {diagnosticScan.isLoading && (
+                    <div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
+                      <Loader2 className="w-3.5 h-3.5 animate-spin" />
+                      {t('bugReport.diagnosticChecking')}
+                    </div>
+                  )}
+                  {!diagnosticScan.isLoading && diagnosticProblems.length > 0 && (
+                    <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.diagnosticHeading')}
+                          </p>
+                          <p className="text-xs text-amber-800 dark:text-amber-200 mt-0.5">
+                            {t('bugReport.diagnosticIntro')}
+                          </p>
+                        </div>
+                      </div>
+                      {diagnosticProblems.map((result) => (
+                        <DiagnosticChecklist key={result.printer_id ?? result.ip_address} result={result} />
+                      ))}
+                    </div>
+                  )}
+                  {!diagnosticScan.isLoading &&
+                    diagnosticResults.length > 0 &&
+                    diagnosticProblems.length === 0 && (
+                      <div className="flex items-start gap-2 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 p-3">
+                        <CheckCircle className="w-4 h-4 mt-0.5 flex-shrink-0 text-green-600 dark:text-green-400" />
+                        <p className="text-xs text-green-800 dark:text-green-200">
+                          {t('bugReport.diagnosticHealthy')}
+                        </p>
+                      </div>
+                    )}
+
                   {/* Description */}
                   {/* Description */}
                   <div>
                   <div>
                     <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
                     <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">

+ 176 - 0
frontend/src/components/ConnectionDiagnostic.tsx

@@ -0,0 +1,176 @@
+import { useEffect } from 'react';
+import { useMutation } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import {
+  X,
+  Stethoscope,
+  CheckCircle2,
+  XCircle,
+  AlertTriangle,
+  MinusCircle,
+  Loader2,
+} from 'lucide-react';
+import {
+  api,
+  type DiagnosticCheck,
+  type DiagnosticStatus,
+  type PrinterDiagnosticResult,
+} from '../api/client';
+
+function StatusIcon({ status }: { status: DiagnosticStatus }) {
+  if (status === 'pass') return <CheckCircle2 className="w-5 h-5 text-bambu-green flex-shrink-0" />;
+  if (status === 'fail') return <XCircle className="w-5 h-5 text-red-400 flex-shrink-0" />;
+  if (status === 'warn') return <AlertTriangle className="w-5 h-5 text-amber-400 flex-shrink-0" />;
+  return <MinusCircle className="w-5 h-5 text-bambu-gray flex-shrink-0" />;
+}
+
+/**
+ * Presentational checklist — renders one row per diagnostic check plus an
+ * overall banner. Shared by the modal and the bug-report panel. The title
+ * and per-status detail text are localized via `diagnostic.check.<id>.*`.
+ */
+export function DiagnosticChecklist({ result }: { result: PrinterDiagnosticResult }) {
+  const { t } = useTranslation();
+
+  const overallClass =
+    result.overall === 'ok'
+      ? 'bg-bambu-green/10 border-bambu-green/30 text-bambu-green'
+      : result.overall === 'warnings'
+        ? 'bg-amber-500/10 border-amber-500/30 text-amber-300'
+        : 'bg-red-500/10 border-red-500/30 text-red-300';
+
+  const renderCheck = (check: DiagnosticCheck) => {
+    const detail = t(`diagnostic.check.${check.id}.${check.status}`, {
+      ...check.params,
+      defaultValue: '',
+    });
+    return (
+      <li
+        key={check.id}
+        className={`flex items-start gap-3 bg-bambu-dark rounded-lg px-4 py-2.5 ${
+          check.status === 'skip' ? 'opacity-60' : ''
+        }`}
+      >
+        <div className="mt-0.5">
+          <StatusIcon status={check.status} />
+        </div>
+        <div className="flex-1 min-w-0">
+          <div className="text-sm text-white">{t(`diagnostic.check.${check.id}.title`)}</div>
+          {detail && <div className="text-xs text-bambu-gray mt-0.5">{detail}</div>}
+        </div>
+      </li>
+    );
+  };
+
+  return (
+    <div className="space-y-4">
+      <ol className="space-y-2">{result.checks.map(renderCheck)}</ol>
+      <div className={`rounded-lg border px-4 py-3 text-sm ${overallClass}`}>
+        {t(`diagnostic.overall.${result.overall}`)}
+      </div>
+    </div>
+  );
+}
+
+type Connection = {
+  ip_address: string;
+  serial_number?: string;
+  access_code?: string;
+};
+
+type ConnectionDiagnosticModalProps = {
+  onClose: () => void;
+  printerName?: string | null;
+} & ({ printerId: number } | { connection: Connection });
+
+/**
+ * Connection diagnostic modal. Opens straight into the test — used from the
+ * printer card, the System page, and the Add-Printer flow on failure.
+ */
+export function ConnectionDiagnosticModal(props: ConnectionDiagnosticModalProps) {
+  const { onClose, printerName } = props;
+  const { t } = useTranslation();
+  const printerId = 'printerId' in props ? props.printerId : undefined;
+  const connection = 'connection' in props ? props.connection : undefined;
+
+  const diagnose = useMutation({
+    mutationFn: (): Promise<PrinterDiagnosticResult> =>
+      printerId !== undefined
+        ? api.diagnosePrinter(printerId)
+        : api.diagnoseConnection(connection as Connection),
+  });
+
+  useEffect(() => {
+    diagnose.mutate();
+    // Run once on mount — re-running is the explicit "Retry" button.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  const result = diagnose.data as PrinterDiagnosticResult | undefined;
+
+  return (
+    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4" onClick={onClose}>
+      <div
+        className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg flex flex-col max-h-[85vh]"
+        onClick={(e) => e.stopPropagation()}
+      >
+        <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
+          <div className="flex items-center gap-2 min-w-0">
+            <Stethoscope className="w-5 h-5 text-bambu-green flex-shrink-0" />
+            <h2 className="text-lg font-semibold text-white truncate">
+              {t('diagnostic.modalTitle', { name: printerName || '' })}
+            </h2>
+          </div>
+          <button
+            onClick={onClose}
+            className="text-bambu-gray hover:text-white transition-colors"
+            title={t('common.close')}
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        <div className="p-6 space-y-4 overflow-y-auto">
+          {diagnose.isPending && (
+            <div className="flex items-center gap-2 text-bambu-gray">
+              <Loader2 className="w-4 h-4 animate-spin" />
+              <span>{t('diagnostic.running')}</span>
+            </div>
+          )}
+
+          {diagnose.isError && (
+            <div className="rounded-lg bg-red-500/10 border border-red-500/30 px-4 py-3 text-sm text-red-300">
+              {t('diagnostic.runFailed', { error: (diagnose.error as Error).message })}
+            </div>
+          )}
+
+          {result && <DiagnosticChecklist result={result} />}
+        </div>
+
+        <div className="px-6 py-4 border-t border-bambu-dark-tertiary flex justify-end gap-2">
+          <button
+            onClick={() => diagnose.mutate()}
+            disabled={diagnose.isPending}
+            className="px-4 py-2 bg-bambu-dark hover:bg-bambu-dark-tertiary disabled:opacity-50 text-white text-sm rounded-lg transition-colors"
+          >
+            {t('diagnostic.retry')}
+          </button>
+          <button
+            onClick={onClose}
+            className="px-4 py-2 bg-bambu-green hover:bg-bambu-green/90 text-white text-sm rounded-lg transition-colors"
+          >
+            {t('common.close')}
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+}

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

@@ -5465,6 +5465,63 @@ export default {
     },
     },
   },
   },
 
 
+  diagnostic: {
+    modalTitle: 'Verbindungsdiagnose — {{name}}',
+    running: 'Diagnose läuft...',
+    runFailed: 'Diagnose konnte nicht ausgeführt werden: {{error}}',
+    retry: 'Erneut ausführen',
+    runButton: 'Diagnose ausführen',
+    sectionTitle: 'Verbindungsdiagnose',
+    sectionDescription: 'Prüfen, warum ein Drucker keine Verbindung herstellt oder nicht druckt — Port-Erreichbarkeit, LAN-Entwicklermodus, Docker-Netzwerkmodus und Zugangsdaten.',
+    noPrinters: 'Keine Drucker konfiguriert.',
+    overall: {
+      ok: 'Keine Probleme gefunden — die Druckerverbindung sieht gut aus.',
+      warnings: 'Der Drucker sollte funktionieren, aber einige Punkte erfordern Aufmerksamkeit.',
+      problems: 'Probleme gefunden, die erklären, warum der Drucker keine Verbindung herstellt oder nicht druckt.',
+    },
+    check: {
+      port_mqtt: {
+        title: 'Steuerungsport (MQTT 8883)',
+        pass: 'Erreichbar — der Drucker akzeptiert Steuerungsverbindungen.',
+        fail: 'Port 8883 ist nicht erreichbar. Der Drucker ist ausgeschaltet, hat eine andere IP-Adresse oder eine Firewall blockiert ihn. Überprüfen Sie die Drucker-IP und dass nichts Port 8883 blockiert.',
+      },
+      port_ftps: {
+        title: 'Dateiübertragungsport (FTPS 990)',
+        pass: 'Erreichbar — das Senden von Druckdateien funktioniert.',
+        warn: 'Port 990 ist nicht erreichbar. Die Überwachung funktioniert möglicherweise weiterhin, aber das Senden von Drucken an den Drucker schlägt fehl. Stellen Sie sicher, dass Port 990 nicht blockiert ist.',
+      },
+      port_rtsps: {
+        title: 'Kameraport (RTSPS 322)',
+        pass: 'Erreichbar — der Kamerastream funktioniert.',
+        warn: 'Port 322 ist nicht erreichbar. Die Live-Kameraansicht funktioniert nicht. Dies betrifft das Drucken nicht.',
+      },
+      network_mode: {
+        title: 'Docker-Netzwerkmodus',
+        pass: 'Läuft im Host-Netzwerkmodus.',
+        warn: 'Bambuddy läuft im Docker-Bridge-Netzwerkmodus. Die Druckererkennung und der virtuelle Drucker benötigen den Host-Netzwerkmodus — erstellen Sie den Container mit "network_mode: host" neu.',
+        skip: 'Läuft nicht in Docker — nicht zutreffend.',
+      },
+      subnet: {
+        title: 'Netzwerk-Subnetz',
+        pass: 'Drucker und Bambuddy befinden sich im selben Subnetz.',
+        warn: 'Der Drucker ({{printer_ip}}) und Bambuddy ({{host_ip}}) befinden sich in unterschiedlichen Subnetzen. Sie können sich möglicherweise nicht erreichen, sofern kein Routing zwischen den Subnetzen konfiguriert ist.',
+        skip: 'Subnetz konnte nicht ermittelt werden — übersprungen.',
+      },
+      mqtt_auth: {
+        title: 'Drucker-Zugangsdaten',
+        pass: 'Der Drucker hat die Verbindung akzeptiert.',
+        fail: 'Der Drucker ist erreichbar, hat die Verbindung aber abgelehnt. Der Zugangscode oder die Seriennummer ist höchstwahrscheinlich falsch. Der Zugangscode ändert sich bei jedem Umschalten des Entwicklermodus — kopieren Sie ihn erneut vom Druckerbildschirm.',
+        skip: 'Nicht geprüft — der Drucker konnte nicht erreicht werden.',
+      },
+      developer_mode: {
+        title: 'LAN-Entwicklermodus',
+        pass: 'Der Entwicklermodus ist aktiviert.',
+        fail: 'Der Entwicklermodus ist am Drucker AUS. Aktivieren Sie ihn in den LAN-Einstellungen des Druckers — und bestätigen Sie mit OK. Ohne ihn starten Drucke nicht.',
+        skip: 'Konnte nicht geprüft werden — erfordert eine aktive Verbindung zum Drucker.',
+      },
+    },
+  },
+
   bugReport: {
   bugReport: {
     title: 'Fehler melden',
     title: 'Fehler melden',
     description: 'Beschreibung',
     description: 'Beschreibung',
@@ -5490,6 +5547,10 @@ export default {
     submitting: 'Fehlerbericht wird gesendet...',
     submitting: 'Fehlerbericht wird gesendet...',
     submitSuccess: 'Fehlerbericht erfolgreich gesendet!',
     submitSuccess: 'Fehlerbericht erfolgreich gesendet!',
     submitFailed: 'Fehlerbericht konnte nicht gesendet werden',
     submitFailed: 'Fehlerbericht konnte nicht gesendet werden',
+    diagnosticChecking: 'Druckerverbindungen werden geprüft...',
+    diagnosticHealthy: 'Verbindungsprüfung bestanden — keine Probleme an Ihren Druckern gefunden.',
+    diagnosticHeading: 'Mögliches Konfigurationsproblem erkannt',
+    diagnosticIntro: 'Ein Drucker hat ein Verbindungsproblem, das die Ursache Ihres Problems sein könnte. Prüfen Sie die Lösung unten — sie zu beheben könnte das Problem ohne Fehlerbericht lösen. Sie können unten dennoch einen Bericht senden.',
     thankYou: 'Vielen Dank!',
     thankYou: 'Vielen Dank!',
     submitted: 'Ihr Fehlerbericht wurde eingereicht.',
     submitted: 'Ihr Fehlerbericht wurde eingereicht.',
     viewIssue: 'Issue ansehen',
     viewIssue: 'Issue ansehen',

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

@@ -5478,6 +5478,63 @@ export default {
     },
     },
   },
   },
 
 
+  diagnostic: {
+    modalTitle: 'Connection diagnostic — {{name}}',
+    running: 'Running diagnostic...',
+    runFailed: 'Diagnostic could not run: {{error}}',
+    retry: 'Run again',
+    runButton: 'Run diagnostic',
+    sectionTitle: 'Connection Diagnostic',
+    sectionDescription: 'Check why a printer won\'t connect or won\'t print — port reachability, LAN developer mode, Docker network mode, and credentials.',
+    noPrinters: 'No printers configured.',
+    overall: {
+      ok: 'No problems found — the printer connection looks healthy.',
+      warnings: 'The printer should work, but some things need attention.',
+      problems: 'Found problems that explain why the printer won\'t connect or print.',
+    },
+    check: {
+      port_mqtt: {
+        title: 'Control port (MQTT 8883)',
+        pass: 'Reachable — the printer is accepting control connections.',
+        fail: 'Port 8883 is unreachable. The printer is powered off, on a different IP address, or a firewall is blocking it. Verify the printer IP and that nothing blocks port 8883.',
+      },
+      port_ftps: {
+        title: 'File transfer port (FTPS 990)',
+        pass: 'Reachable — sending print files will work.',
+        warn: 'Port 990 is unreachable. Monitoring may still work, but sending prints to the printer will fail. Make sure port 990 is not blocked.',
+      },
+      port_rtsps: {
+        title: 'Camera port (RTSPS 322)',
+        pass: 'Reachable — the camera stream will work.',
+        warn: 'Port 322 is unreachable. The live camera view will not work. This does not affect printing.',
+      },
+      network_mode: {
+        title: 'Docker network mode',
+        pass: 'Running in host network mode.',
+        warn: 'Bambuddy is running in Docker bridge networking. Printer discovery and the Virtual Printer need host network mode — recreate the container with "network_mode: host".',
+        skip: 'Not running in Docker — not applicable.',
+      },
+      subnet: {
+        title: 'Network subnet',
+        pass: 'The printer and Bambuddy are on the same subnet.',
+        warn: 'The printer ({{printer_ip}}) and Bambuddy ({{host_ip}}) are on different subnets. They may not reach each other unless routing between the subnets is configured.',
+        skip: 'Subnet could not be determined — skipped.',
+      },
+      mqtt_auth: {
+        title: 'Printer credentials',
+        pass: 'The printer accepted the connection.',
+        fail: 'The printer is reachable but rejected the connection. The access code or serial number is most likely wrong. The access code changes every time Developer Mode is toggled — re-copy it from the printer screen.',
+        skip: 'Not checked — the printer could not be reached.',
+      },
+      developer_mode: {
+        title: 'LAN Developer Mode',
+        pass: 'Developer Mode is enabled.',
+        fail: 'Developer Mode is OFF on the printer. Enable it in the printer\'s LAN settings — and confirm with OK. Without it, prints will not start.',
+        skip: 'Could not be checked — requires a live connection to the printer.',
+      },
+    },
+  },
+
   bugReport: {
   bugReport: {
     title: 'Report a Bug',
     title: 'Report a Bug',
     description: 'Description',
     description: 'Description',
@@ -5503,6 +5560,10 @@ export default {
     submitting: 'Submitting bug report...',
     submitting: 'Submitting bug report...',
     submitSuccess: 'Bug report submitted successfully!',
     submitSuccess: 'Bug report submitted successfully!',
     submitFailed: 'Failed to submit bug report',
     submitFailed: 'Failed to submit bug report',
+    diagnosticChecking: 'Checking printer connections...',
+    diagnosticHealthy: 'Connection check passed — no problems found on your printers.',
+    diagnosticHeading: 'Possible setup issue detected',
+    diagnosticIntro: 'A printer has a connection problem that may be causing your issue. Check the fix below — resolving it could solve the problem without a bug report. You can still submit a report below.',
     thankYou: 'Thank you!',
     thankYou: 'Thank you!',
     submitted: 'Your bug report has been submitted.',
     submitted: 'Your bug report has been submitted.',
     viewIssue: 'View Issue',
     viewIssue: 'View Issue',

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

@@ -5455,6 +5455,63 @@ export default {
     },
     },
   },
   },
 
 
+  diagnostic: {
+    modalTitle: 'Diagnostic de connexion — {{name}}',
+    running: 'Diagnostic en cours...',
+    runFailed: 'Le diagnostic n\'a pas pu s\'exécuter : {{error}}',
+    retry: 'Relancer',
+    runButton: 'Lancer le diagnostic',
+    sectionTitle: 'Diagnostic de connexion',
+    sectionDescription: 'Vérifiez pourquoi une imprimante ne se connecte pas ou n\'imprime pas — accessibilité des ports, mode développeur LAN, mode réseau Docker et identifiants.',
+    noPrinters: 'Aucune imprimante configurée.',
+    overall: {
+      ok: 'Aucun problème détecté — la connexion de l\'imprimante semble correcte.',
+      warnings: 'L\'imprimante devrait fonctionner, mais certains points nécessitent votre attention.',
+      problems: 'Des problèmes expliquant pourquoi l\'imprimante ne se connecte pas ou n\'imprime pas ont été détectés.',
+    },
+    check: {
+      port_mqtt: {
+        title: 'Port de contrôle (MQTT 8883)',
+        pass: 'Accessible — l\'imprimante accepte les connexions de contrôle.',
+        fail: 'Le port 8883 est inaccessible. L\'imprimante est éteinte, possède une autre adresse IP, ou un pare-feu la bloque. Vérifiez l\'IP de l\'imprimante et qu\'aucun élément ne bloque le port 8883.',
+      },
+      port_ftps: {
+        title: 'Port de transfert de fichiers (FTPS 990)',
+        pass: 'Accessible — l\'envoi de fichiers d\'impression fonctionnera.',
+        warn: 'Le port 990 est inaccessible. La surveillance peut toujours fonctionner, mais l\'envoi d\'impressions vers l\'imprimante échouera. Assurez-vous que le port 990 n\'est pas bloqué.',
+      },
+      port_rtsps: {
+        title: 'Port caméra (RTSPS 322)',
+        pass: 'Accessible — le flux de la caméra fonctionnera.',
+        warn: 'Le port 322 est inaccessible. La vue caméra en direct ne fonctionnera pas. Cela n\'affecte pas l\'impression.',
+      },
+      network_mode: {
+        title: 'Mode réseau Docker',
+        pass: 'Fonctionne en mode réseau host.',
+        warn: 'Bambuddy fonctionne en réseau Docker bridge. La découverte d\'imprimantes et l\'imprimante virtuelle nécessitent le mode réseau host — recréez le conteneur avec "network_mode: host".',
+        skip: 'Ne fonctionne pas dans Docker — non applicable.',
+      },
+      subnet: {
+        title: 'Sous-réseau',
+        pass: 'L\'imprimante et Bambuddy sont sur le même sous-réseau.',
+        warn: 'L\'imprimante ({{printer_ip}}) et Bambuddy ({{host_ip}}) sont sur des sous-réseaux différents. Ils peuvent ne pas se joindre, sauf si un routage entre les sous-réseaux est configuré.',
+        skip: 'Le sous-réseau n\'a pas pu être déterminé — ignoré.',
+      },
+      mqtt_auth: {
+        title: 'Identifiants de l\'imprimante',
+        pass: 'L\'imprimante a accepté la connexion.',
+        fail: 'L\'imprimante est accessible mais a refusé la connexion. Le code d\'accès ou le numéro de série est très probablement incorrect. Le code d\'accès change chaque fois que le mode développeur est activé/désactivé — recopiez-le depuis l\'écran de l\'imprimante.',
+        skip: 'Non vérifié — l\'imprimante n\'a pas pu être jointe.',
+      },
+      developer_mode: {
+        title: 'Mode développeur LAN',
+        pass: 'Le mode développeur est activé.',
+        fail: 'Le mode développeur est DÉSACTIVÉ sur l\'imprimante. Activez-le dans les paramètres LAN de l\'imprimante — et confirmez avec OK. Sans lui, les impressions ne démarreront pas.',
+        skip: 'Impossible à vérifier — nécessite une connexion active à l\'imprimante.',
+      },
+    },
+  },
+
   bugReport: {
   bugReport: {
     title: 'Signaler un bug',
     title: 'Signaler un bug',
     description: 'Description',
     description: 'Description',
@@ -5480,6 +5537,10 @@ export default {
     submitting: 'Envoi du rapport de bug...',
     submitting: 'Envoi du rapport de bug...',
     submitSuccess: 'Rapport de bug envoyé avec succès !',
     submitSuccess: 'Rapport de bug envoyé avec succès !',
     submitFailed: 'Échec de l\'envoi du rapport de bug',
     submitFailed: 'Échec de l\'envoi du rapport de bug',
+    diagnosticChecking: 'Vérification des connexions des imprimantes...',
+    diagnosticHealthy: 'Vérification de connexion réussie — aucun problème détecté sur vos imprimantes.',
+    diagnosticHeading: 'Problème de configuration possible détecté',
+    diagnosticIntro: 'Une imprimante a un problème de connexion qui pourrait être à l\'origine de votre problème. Consultez la solution ci-dessous — la résoudre pourrait régler le problème sans rapport de bug. Vous pouvez tout de même envoyer un rapport ci-dessous.',
     thankYou: 'Merci !',
     thankYou: 'Merci !',
     submitted: 'Votre rapport de bug a été soumis.',
     submitted: 'Votre rapport de bug a été soumis.',
     viewIssue: 'Voir l\'issue',
     viewIssue: 'Voir l\'issue',

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

@@ -5454,6 +5454,63 @@ export default {
     },
     },
   },
   },
 
 
+  diagnostic: {
+    modalTitle: 'Diagnostica connessione — {{name}}',
+    running: 'Diagnostica in corso...',
+    runFailed: 'Impossibile eseguire la diagnostica: {{error}}',
+    retry: 'Esegui di nuovo',
+    runButton: 'Esegui diagnostica',
+    sectionTitle: 'Diagnostica connessione',
+    sectionDescription: 'Verifica perché una stampante non si connette o non stampa — raggiungibilità delle porte, modalità sviluppatore LAN, modalità di rete Docker e credenziali.',
+    noPrinters: 'Nessuna stampante configurata.',
+    overall: {
+      ok: 'Nessun problema rilevato — la connessione della stampante sembra a posto.',
+      warnings: 'La stampante dovrebbe funzionare, ma alcuni aspetti richiedono attenzione.',
+      problems: 'Sono stati rilevati problemi che spiegano perché la stampante non si connette o non stampa.',
+    },
+    check: {
+      port_mqtt: {
+        title: 'Porta di controllo (MQTT 8883)',
+        pass: 'Raggiungibile — la stampante accetta connessioni di controllo.',
+        fail: 'La porta 8883 non è raggiungibile. La stampante è spenta, ha un altro indirizzo IP oppure un firewall la blocca. Verifica l\'IP della stampante e che nulla blocchi la porta 8883.',
+      },
+      port_ftps: {
+        title: 'Porta trasferimento file (FTPS 990)',
+        pass: 'Raggiungibile — l\'invio dei file di stampa funzionerà.',
+        warn: 'La porta 990 non è raggiungibile. Il monitoraggio potrebbe ancora funzionare, ma l\'invio delle stampe alla stampante fallirà. Assicurati che la porta 990 non sia bloccata.',
+      },
+      port_rtsps: {
+        title: 'Porta fotocamera (RTSPS 322)',
+        pass: 'Raggiungibile — lo streaming della fotocamera funzionerà.',
+        warn: 'La porta 322 non è raggiungibile. La visualizzazione live della fotocamera non funzionerà. Questo non influisce sulla stampa.',
+      },
+      network_mode: {
+        title: 'Modalità di rete Docker',
+        pass: 'In esecuzione in modalità di rete host.',
+        warn: 'Bambuddy è in esecuzione con la rete Docker bridge. Il rilevamento delle stampanti e la stampante virtuale richiedono la modalità di rete host — ricrea il container con "network_mode: host".',
+        skip: 'Non in esecuzione in Docker — non applicabile.',
+      },
+      subnet: {
+        title: 'Sottorete',
+        pass: 'La stampante e Bambuddy sono nella stessa sottorete.',
+        warn: 'La stampante ({{printer_ip}}) e Bambuddy ({{host_ip}}) sono in sottoreti diverse. Potrebbero non raggiungersi a meno che non sia configurato il routing tra le sottoreti.',
+        skip: 'Impossibile determinare la sottorete — ignorato.',
+      },
+      mqtt_auth: {
+        title: 'Credenziali stampante',
+        pass: 'La stampante ha accettato la connessione.',
+        fail: 'La stampante è raggiungibile ma ha rifiutato la connessione. Il codice di accesso o il numero di serie è molto probabilmente errato. Il codice di accesso cambia ogni volta che la modalità sviluppatore viene attivata/disattivata — ricopialo dallo schermo della stampante.',
+        skip: 'Non verificato — impossibile raggiungere la stampante.',
+      },
+      developer_mode: {
+        title: 'Modalità sviluppatore LAN',
+        pass: 'La modalità sviluppatore è attivata.',
+        fail: 'La modalità sviluppatore è DISATTIVATA sulla stampante. Attivala nelle impostazioni LAN della stampante — e conferma con OK. Senza di essa le stampe non verranno avviate.',
+        skip: 'Impossibile verificare — richiede una connessione attiva alla stampante.',
+      },
+    },
+  },
+
   bugReport: {
   bugReport: {
     title: 'Segnala un bug',
     title: 'Segnala un bug',
     description: 'Descrizione',
     description: 'Descrizione',
@@ -5479,6 +5536,10 @@ export default {
     submitting: 'Invio segnalazione bug...',
     submitting: 'Invio segnalazione bug...',
     submitSuccess: 'Segnalazione bug inviata con successo!',
     submitSuccess: 'Segnalazione bug inviata con successo!',
     submitFailed: 'Impossibile inviare la segnalazione bug',
     submitFailed: 'Impossibile inviare la segnalazione bug',
+    diagnosticChecking: 'Verifica delle connessioni delle stampanti...',
+    diagnosticHealthy: 'Verifica della connessione superata — nessun problema rilevato sulle tue stampanti.',
+    diagnosticHeading: 'Possibile problema di configurazione rilevato',
+    diagnosticIntro: 'Una stampante ha un problema di connessione che potrebbe essere la causa del tuo problema. Controlla la soluzione qui sotto — risolverla potrebbe sistemare il problema senza una segnalazione di bug. Puoi comunque inviare una segnalazione qui sotto.',
     thankYou: 'Grazie!',
     thankYou: 'Grazie!',
     submitted: 'La tua segnalazione bug è stata inviata.',
     submitted: 'La tua segnalazione bug è stata inviata.',
     viewIssue: 'Vedi issue',
     viewIssue: 'Vedi issue',

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

@@ -5466,6 +5466,63 @@ export default {
     },
     },
   },
   },
 
 
+  diagnostic: {
+    modalTitle: '接続診断 — {{name}}',
+    running: '診断を実行中...',
+    runFailed: '診断を実行できませんでした: {{error}}',
+    retry: '再実行',
+    runButton: '診断を実行',
+    sectionTitle: '接続診断',
+    sectionDescription: 'プリンターが接続できない、または印刷できない原因を確認します — ポートの到達性、LAN開発者モード、Dockerネットワークモード、認証情報。',
+    noPrinters: 'プリンターが設定されていません。',
+    overall: {
+      ok: '問題は見つかりませんでした — プリンター接続は正常のようです。',
+      warnings: 'プリンターは動作するはずですが、いくつかの点に注意が必要です。',
+      problems: 'プリンターが接続できない、または印刷できない原因となる問題が見つかりました。',
+    },
+    check: {
+      port_mqtt: {
+        title: '制御ポート (MQTT 8883)',
+        pass: '到達可能 — プリンターは制御接続を受け付けています。',
+        fail: 'ポート8883に到達できません。プリンターの電源が切れている、IPアドレスが異なる、またはファイアウォールがブロックしています。プリンターのIPと、ポート8883が何にもブロックされていないことを確認してください。',
+      },
+      port_ftps: {
+        title: 'ファイル転送ポート (FTPS 990)',
+        pass: '到達可能 — 印刷ファイルの送信は機能します。',
+        warn: 'ポート990に到達できません。監視は引き続き機能する場合がありますが、プリンターへの印刷送信は失敗します。ポート990がブロックされていないことを確認してください。',
+      },
+      port_rtsps: {
+        title: 'カメラポート (RTSPS 322)',
+        pass: '到達可能 — カメラストリームは機能します。',
+        warn: 'ポート322に到達できません。ライブカメラ表示は機能しません。これは印刷には影響しません。',
+      },
+      network_mode: {
+        title: 'Dockerネットワークモード',
+        pass: 'ホストネットワークモードで実行中です。',
+        warn: 'BambuddyはDockerブリッジネットワークで実行されています。プリンター検出と仮想プリンターにはホストネットワークモードが必要です — "network_mode: host" でコンテナを再作成してください。',
+        skip: 'Dockerで実行されていません — 該当しません。',
+      },
+      subnet: {
+        title: 'ネットワークサブネット',
+        pass: 'プリンターとBambuddyは同じサブネットにあります。',
+        warn: 'プリンター ({{printer_ip}}) とBambuddy ({{host_ip}}) は異なるサブネットにあります。サブネット間のルーティングが設定されていない限り、互いに到達できない可能性があります。',
+        skip: 'サブネットを判定できませんでした — スキップしました。',
+      },
+      mqtt_auth: {
+        title: 'プリンター認証情報',
+        pass: 'プリンターが接続を受け入れました。',
+        fail: 'プリンターには到達できますが、接続を拒否されました。アクセスコードまたはシリアル番号が間違っている可能性が高いです。アクセスコードは開発者モードを切り替えるたびに変わります — プリンター画面から再度コピーしてください。',
+        skip: '未確認 — プリンターに到達できませんでした。',
+      },
+      developer_mode: {
+        title: 'LAN開発者モード',
+        pass: '開発者モードは有効です。',
+        fail: 'プリンターの開発者モードがオフです。プリンターのLAN設定で有効にし、OKで確定してください。これがないと印刷は開始されません。',
+        skip: '確認できませんでした — プリンターへのアクティブな接続が必要です。',
+      },
+    },
+  },
+
   bugReport: {
   bugReport: {
     title: 'バグを報告',
     title: 'バグを報告',
     description: '説明',
     description: '説明',
@@ -5491,6 +5548,10 @@ export default {
     submitting: 'バグレポートを送信中...',
     submitting: 'バグレポートを送信中...',
     submitSuccess: 'バグレポートが正常に送信されました!',
     submitSuccess: 'バグレポートが正常に送信されました!',
     submitFailed: 'バグレポートの送信に失敗しました',
     submitFailed: 'バグレポートの送信に失敗しました',
+    diagnosticChecking: 'プリンター接続を確認中...',
+    diagnosticHealthy: '接続チェックに合格しました — プリンターに問題は見つかりませんでした。',
+    diagnosticHeading: '設定の問題の可能性を検出しました',
+    diagnosticIntro: 'あるプリンターに接続の問題があり、それが今回の問題の原因である可能性があります。下記の対処法を確認してください — それを解決すれば、バグ報告なしで問題が解決するかもしれません。下記から報告を送信することもできます。',
     thankYou: 'ありがとうございます!',
     thankYou: 'ありがとうございます!',
     submitted: 'バグレポートが送信されました。',
     submitted: 'バグレポートが送信されました。',
     viewIssue: 'Issueを表示',
     viewIssue: 'Issueを表示',

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

@@ -5454,6 +5454,63 @@ export default {
     },
     },
   },
   },
 
 
+  diagnostic: {
+    modalTitle: 'Diagnóstico de conexão — {{name}}',
+    running: 'Executando diagnóstico...',
+    runFailed: 'Não foi possível executar o diagnóstico: {{error}}',
+    retry: 'Executar novamente',
+    runButton: 'Executar diagnóstico',
+    sectionTitle: 'Diagnóstico de conexão',
+    sectionDescription: 'Verifique por que uma impressora não conecta ou não imprime — acessibilidade das portas, modo desenvolvedor LAN, modo de rede Docker e credenciais.',
+    noPrinters: 'Nenhuma impressora configurada.',
+    overall: {
+      ok: 'Nenhum problema encontrado — a conexão da impressora parece saudável.',
+      warnings: 'A impressora deve funcionar, mas alguns pontos exigem atenção.',
+      problems: 'Foram encontrados problemas que explicam por que a impressora não conecta ou não imprime.',
+    },
+    check: {
+      port_mqtt: {
+        title: 'Porta de controle (MQTT 8883)',
+        pass: 'Acessível — a impressora está aceitando conexões de controle.',
+        fail: 'A porta 8883 está inacessível. A impressora está desligada, em outro endereço IP, ou um firewall está bloqueando. Verifique o IP da impressora e se nada bloqueia a porta 8883.',
+      },
+      port_ftps: {
+        title: 'Porta de transferência de arquivos (FTPS 990)',
+        pass: 'Acessível — o envio de arquivos de impressão funcionará.',
+        warn: 'A porta 990 está inacessível. O monitoramento ainda pode funcionar, mas o envio de impressões para a impressora falhará. Verifique se a porta 990 não está bloqueada.',
+      },
+      port_rtsps: {
+        title: 'Porta da câmera (RTSPS 322)',
+        pass: 'Acessível — o streaming da câmera funcionará.',
+        warn: 'A porta 322 está inacessível. A visualização ao vivo da câmera não funcionará. Isso não afeta a impressão.',
+      },
+      network_mode: {
+        title: 'Modo de rede Docker',
+        pass: 'Executando no modo de rede host.',
+        warn: 'O Bambuddy está sendo executado em rede Docker bridge. A descoberta de impressoras e a impressora virtual precisam do modo de rede host — recrie o contêiner com "network_mode: host".',
+        skip: 'Não está sendo executado no Docker — não aplicável.',
+      },
+      subnet: {
+        title: 'Sub-rede',
+        pass: 'A impressora e o Bambuddy estão na mesma sub-rede.',
+        warn: 'A impressora ({{printer_ip}}) e o Bambuddy ({{host_ip}}) estão em sub-redes diferentes. Eles podem não se alcançar, a menos que o roteamento entre as sub-redes esteja configurado.',
+        skip: 'Não foi possível determinar a sub-rede — ignorado.',
+      },
+      mqtt_auth: {
+        title: 'Credenciais da impressora',
+        pass: 'A impressora aceitou a conexão.',
+        fail: 'A impressora está acessível mas recusou a conexão. O código de acesso ou o número de série provavelmente está incorreto. O código de acesso muda toda vez que o Modo Desenvolvedor é alternado — copie-o novamente da tela da impressora.',
+        skip: 'Não verificado — não foi possível alcançar a impressora.',
+      },
+      developer_mode: {
+        title: 'Modo Desenvolvedor LAN',
+        pass: 'O Modo Desenvolvedor está ativado.',
+        fail: 'O Modo Desenvolvedor está DESLIGADO na impressora. Ative-o nas configurações de LAN da impressora — e confirme com OK. Sem ele, as impressões não iniciarão.',
+        skip: 'Não foi possível verificar — requer uma conexão ativa com a impressora.',
+      },
+    },
+  },
+
   bugReport: {
   bugReport: {
     title: 'Reportar um bug',
     title: 'Reportar um bug',
     description: 'Descrição',
     description: 'Descrição',
@@ -5479,6 +5536,10 @@ export default {
     submitting: 'Enviando relatório de bug...',
     submitting: 'Enviando relatório de bug...',
     submitSuccess: 'Relatório de bug enviado com sucesso!',
     submitSuccess: 'Relatório de bug enviado com sucesso!',
     submitFailed: 'Falha ao enviar relatório de bug',
     submitFailed: 'Falha ao enviar relatório de bug',
+    diagnosticChecking: 'Verificando as conexões das impressoras...',
+    diagnosticHealthy: 'Verificação de conexão aprovada — nenhum problema encontrado nas suas impressoras.',
+    diagnosticHeading: 'Possível problema de configuração detectado',
+    diagnosticIntro: 'Uma impressora tem um problema de conexão que pode estar causando seu problema. Confira a solução abaixo — resolvê-la pode solucionar o problema sem um relatório de bug. Você ainda pode enviar um relatório abaixo.',
     thankYou: 'Obrigado!',
     thankYou: 'Obrigado!',
     submitted: 'Seu relatório de bug foi enviado.',
     submitted: 'Seu relatório de bug foi enviado.',
     viewIssue: 'Ver issue',
     viewIssue: 'Ver issue',

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

@@ -5453,6 +5453,63 @@ export default {
     },
     },
   },
   },
 
 
+  diagnostic: {
+    modalTitle: '连接诊断 — {{name}}',
+    running: '正在运行诊断...',
+    runFailed: '无法运行诊断:{{error}}',
+    retry: '重新运行',
+    runButton: '运行诊断',
+    sectionTitle: '连接诊断',
+    sectionDescription: '检查打印机无法连接或无法打印的原因 — 端口可达性、LAN 开发者模式、Docker 网络模式和凭据。',
+    noPrinters: '未配置打印机。',
+    overall: {
+      ok: '未发现问题 — 打印机连接看起来正常。',
+      warnings: '打印机应该可以工作,但有些事项需要注意。',
+      problems: '发现了可解释打印机无法连接或无法打印的问题。',
+    },
+    check: {
+      port_mqtt: {
+        title: '控制端口(MQTT 8883)',
+        pass: '可达 — 打印机正在接受控制连接。',
+        fail: '端口 8883 不可达。打印机已关机、IP 地址不同,或被防火墙阻止。请核实打印机 IP,并确保没有任何东西阻止端口 8883。',
+      },
+      port_ftps: {
+        title: '文件传输端口(FTPS 990)',
+        pass: '可达 — 发送打印文件将正常工作。',
+        warn: '端口 990 不可达。监控可能仍然有效,但向打印机发送打印任务将失败。请确保端口 990 未被阻止。',
+      },
+      port_rtsps: {
+        title: '摄像头端口(RTSPS 322)',
+        pass: '可达 — 摄像头视频流将正常工作。',
+        warn: '端口 322 不可达。实时摄像头视图将无法工作。这不影响打印。',
+      },
+      network_mode: {
+        title: 'Docker 网络模式',
+        pass: '正在以 host 网络模式运行。',
+        warn: 'Bambuddy 正在以 Docker bridge 网络运行。打印机发现和虚拟打印机需要 host 网络模式 — 请使用 "network_mode: host" 重新创建容器。',
+        skip: '未在 Docker 中运行 — 不适用。',
+      },
+      subnet: {
+        title: '网络子网',
+        pass: '打印机和 Bambuddy 位于同一子网。',
+        warn: '打印机({{printer_ip}})和 Bambuddy({{host_ip}})位于不同的子网。除非配置了子网之间的路由,否则它们可能无法相互访问。',
+        skip: '无法确定子网 — 已跳过。',
+      },
+      mqtt_auth: {
+        title: '打印机凭据',
+        pass: '打印机已接受连接。',
+        fail: '打印机可达,但拒绝了连接。访问代码或序列号很可能有误。每次切换开发者模式时访问代码都会更改 — 请从打印机屏幕重新复制。',
+        skip: '未检查 — 无法连接到打印机。',
+      },
+      developer_mode: {
+        title: 'LAN 开发者模式',
+        pass: '开发者模式已启用。',
+        fail: '打印机上的开发者模式已关闭。请在打印机的 LAN 设置中启用它 — 并按 OK 确认。否则打印将无法开始。',
+        skip: '无法检查 — 需要与打印机的实时连接。',
+      },
+    },
+  },
+
   bugReport: {
   bugReport: {
     title: '报告错误',
     title: '报告错误',
     description: '描述',
     description: '描述',
@@ -5478,6 +5535,10 @@ export default {
     submitting: '正在提交错误报告...',
     submitting: '正在提交错误报告...',
     submitSuccess: '错误报告提交成功!',
     submitSuccess: '错误报告提交成功!',
     submitFailed: '提交错误报告失败',
     submitFailed: '提交错误报告失败',
+    diagnosticChecking: '正在检查打印机连接...',
+    diagnosticHealthy: '连接检查通过 — 未在您的打印机上发现问题。',
+    diagnosticHeading: '检测到可能的设置问题',
+    diagnosticIntro: '某台打印机存在连接问题,可能正是您遇到问题的原因。请查看下方的解决方法 — 解决它也许无需提交错误报告即可解决问题。您仍然可以在下方提交报告。',
     thankYou: '谢谢!',
     thankYou: '谢谢!',
     submitted: '您的错误报告已提交。',
     submitted: '您的错误报告已提交。',
     viewIssue: '查看Issue',
     viewIssue: '查看Issue',

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

@@ -5453,6 +5453,63 @@ export default {
     },
     },
   },
   },
 
 
+  diagnostic: {
+    modalTitle: '連線診斷 — {{name}}',
+    running: '正在執行診斷...',
+    runFailed: '無法執行診斷:{{error}}',
+    retry: '重新執行',
+    runButton: '執行診斷',
+    sectionTitle: '連線診斷',
+    sectionDescription: '檢查印表機無法連線或無法列印的原因 — 連接埠可達性、LAN 開發者模式、Docker 網路模式與認證資訊。',
+    noPrinters: '未設定印表機。',
+    overall: {
+      ok: '未發現問題 — 印表機連線看起來正常。',
+      warnings: '印表機應該可以運作,但有些項目需要注意。',
+      problems: '發現了可解釋印表機無法連線或無法列印的問題。',
+    },
+    check: {
+      port_mqtt: {
+        title: '控制連接埠(MQTT 8883)',
+        pass: '可達 — 印表機正在接受控制連線。',
+        fail: '連接埠 8883 無法連線。印表機已關機、IP 位址不同,或被防火牆封鎖。請核實印表機 IP,並確保沒有任何東西封鎖連接埠 8883。',
+      },
+      port_ftps: {
+        title: '檔案傳輸連接埠(FTPS 990)',
+        pass: '可達 — 傳送列印檔案將正常運作。',
+        warn: '連接埠 990 無法連線。監控可能仍然有效,但向印表機傳送列印工作將失敗。請確保連接埠 990 未被封鎖。',
+      },
+      port_rtsps: {
+        title: '攝影機連接埠(RTSPS 322)',
+        pass: '可達 — 攝影機串流將正常運作。',
+        warn: '連接埠 322 無法連線。即時攝影機檢視將無法運作。這不影響列印。',
+      },
+      network_mode: {
+        title: 'Docker 網路模式',
+        pass: '正在以 host 網路模式執行。',
+        warn: 'Bambuddy 正在以 Docker bridge 網路執行。印表機探索與虛擬印表機需要 host 網路模式 — 請使用 "network_mode: host" 重新建立容器。',
+        skip: '未在 Docker 中執行 — 不適用。',
+      },
+      subnet: {
+        title: '網路子網路',
+        pass: '印表機與 Bambuddy 位於同一子網路。',
+        warn: '印表機({{printer_ip}})與 Bambuddy({{host_ip}})位於不同的子網路。除非設定了子網路之間的路由,否則它們可能無法互相連線。',
+        skip: '無法判定子網路 — 已略過。',
+      },
+      mqtt_auth: {
+        title: '印表機認證資訊',
+        pass: '印表機已接受連線。',
+        fail: '印表機可達,但拒絕了連線。存取碼或序號很可能有誤。每次切換開發者模式時存取碼都會變更 — 請從印表機螢幕重新複製。',
+        skip: '未檢查 — 無法連線到印表機。',
+      },
+      developer_mode: {
+        title: 'LAN 開發者模式',
+        pass: '開發者模式已啟用。',
+        fail: '印表機上的開發者模式已關閉。請在印表機的 LAN 設定中啟用它 — 並按 OK 確認。否則列印將無法開始。',
+        skip: '無法檢查 — 需要與印表機的即時連線。',
+      },
+    },
+  },
+
   bugReport: {
   bugReport: {
     title: '報告錯誤',
     title: '報告錯誤',
     description: '描述',
     description: '描述',
@@ -5478,6 +5535,10 @@ export default {
     submitting: '正在提交錯誤報告...',
     submitting: '正在提交錯誤報告...',
     submitSuccess: '錯誤報告提交成功!',
     submitSuccess: '錯誤報告提交成功!',
     submitFailed: '提交錯誤報告失敗',
     submitFailed: '提交錯誤報告失敗',
+    diagnosticChecking: '正在檢查印表機連線...',
+    diagnosticHealthy: '連線檢查通過 — 未在您的印表機上發現問題。',
+    diagnosticHeading: '偵測到可能的設定問題',
+    diagnosticIntro: '某台印表機存在連線問題,可能正是您遇到問題的原因。請查看下方的解決方法 — 解決它也許無需提交錯誤報告即可解決問題。您仍然可以在下方提交報告。',
     thankYou: '謝謝!',
     thankYou: '謝謝!',
     submitted: '您的錯誤報告已提交。',
     submitted: '您的錯誤報告已提交。',
     viewIssue: '檢視 Issue',
     viewIssue: '檢視 Issue',

+ 56 - 1
frontend/src/pages/PrintersPage.tsx

@@ -69,6 +69,7 @@ import {
   LogOut,
   LogOut,
   MoreHorizontal,
   MoreHorizontal,
   SlidersHorizontal,
   SlidersHorizontal,
+  Stethoscope,
 } from 'lucide-react';
 } from 'lucide-react';
 
 
 import { useNavigate } from 'react-router-dom';
 import { useNavigate } from 'react-router-dom';
@@ -100,6 +101,7 @@ import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoo
 import { getPrinterImage, getWifiStrength, filterCompatibleQueueItems } from '../utils/printer';
 import { getPrinterImage, getWifiStrength, filterCompatibleQueueItems } from '../utils/printer';
 import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
 import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
 import { Collapsible } from '../components/Collapsible';
 import { Collapsible } from '../components/Collapsible';
+import { ConnectionDiagnosticModal } from '../components/ConnectionDiagnostic';
 import { getColorName, parseFilamentColor, isLightColor } from '../utils/colors';
 import { getColorName, parseFilamentColor, isLightColor } from '../utils/colors';
 
 
 export interface SpoolmanSlotAssignmentRow {
 export interface SpoolmanSlotAssignmentRow {
@@ -1511,6 +1513,7 @@ function PrinterCard({
   const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);
   const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);
   const [showUploadForPrint, setShowUploadForPrint] = useState(false);
   const [showUploadForPrint, setShowUploadForPrint] = useState(false);
   const [showPrinterInfo, setShowPrinterInfo] = useState(false);
   const [showPrinterInfo, setShowPrinterInfo] = useState(false);
+  const [showDiagnostic, setShowDiagnostic] = useState(false);
   const closePrinterInfo = useCallback(() => setShowPrinterInfo(false), []);
   const closePrinterInfo = useCallback(() => setShowPrinterInfo(false), []);
   const [printAfterUpload, setPrintAfterUpload] = useState<{ id: number; filename: string } | null>(null);
   const [printAfterUpload, setPrintAfterUpload] = useState<{ id: number; filename: string } | null>(null);
   // AMS drying popover state: which AMS unit has the popover open
   // AMS drying popover state: which AMS unit has the popover open
@@ -2590,6 +2593,16 @@ function PrinterCard({
                     <Terminal className="w-4 h-4" />
                     <Terminal className="w-4 h-4" />
                     {t('printers.mqttDebug')}
                     {t('printers.mqttDebug')}
                   </button>
                   </button>
+                  <button
+                    className="w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2"
+                    onClick={() => {
+                      setShowDiagnostic(true);
+                      setShowMenu(false);
+                    }}
+                  >
+                    <Stethoscope className="w-4 h-4" />
+                    {t('diagnostic.runButton')}
+                  </button>
                   <button
                   <button
                     className={`w-full px-4 py-2 text-left text-sm flex items-center gap-2 ${
                     className={`w-full px-4 py-2 text-left text-sm flex items-center gap-2 ${
                       hasPermission('printers:delete')
                       hasPermission('printers:delete')
@@ -2629,6 +2642,17 @@ function PrinterCard({
                 )}
                 )}
                 {status?.connected ? t('printers.connection.connected') : t('printers.connection.offline')}
                 {status?.connected ? t('printers.connection.connected') : t('printers.connection.offline')}
               </span>
               </span>
+              {/* Run connection diagnostic — offered when the printer is offline */}
+              {!status?.connected && (
+                <button
+                  onClick={() => setShowDiagnostic(true)}
+                  className="flex items-center gap-1 px-2 py-1 rounded-full text-xs cursor-pointer bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
+                  title={t('diagnostic.runButton')}
+                >
+                  <Stethoscope className="w-3 h-3" />
+                  {t('diagnostic.runButton')}
+                </button>
+              )}
               {/* Network connection indicator */}
               {/* Network connection indicator */}
               {status?.connected && status?.wired_network && (
               {status?.connected && status?.wired_network && (
                 <span
                 <span
@@ -4867,6 +4891,14 @@ function PrinterCard({
         />
         />
       )}
       )}
 
 
+      {showDiagnostic && (
+        <ConnectionDiagnosticModal
+          printerId={printer.id}
+          printerName={printer.name}
+          onClose={() => setShowDiagnostic(false)}
+        />
+      )}
+
       {showPrinterInfo && (
       {showPrinterInfo && (
         <PrinterInfoModal
         <PrinterInfoModal
           printer={printer}
           printer={printer}
@@ -5561,6 +5593,7 @@ function AddPrinterModal({
   const [detectedSubnets, setDetectedSubnets] = useState<string[]>([]);
   const [detectedSubnets, setDetectedSubnets] = useState<string[]>([]);
   const [subnet, setSubnet] = useState('');
   const [subnet, setSubnet] = useState('');
   const [scanProgress, setScanProgress] = useState({ scanned: 0, total: 0 });
   const [scanProgress, setScanProgress] = useState({ scanned: 0, total: 0 });
+  const [showDiagnostic, setShowDiagnostic] = useState(false);
 
 
   // Fetch discovery info on mount
   // Fetch discovery info on mount
   useEffect(() => {
   useEffect(() => {
@@ -5683,6 +5716,7 @@ function AddPrinterModal({
   }, [onClose]);
   }, [onClose]);
 
 
   return (
   return (
+    <>
     <div
     <div
       className="fixed inset-0 bg-black/50 flex items-start sm:items-center justify-center z-50 p-4 overflow-y-auto"
       className="fixed inset-0 bg-black/50 flex items-start sm:items-center justify-center z-50 p-4 overflow-y-auto"
       onClick={onClose}
       onClick={onClose}
@@ -5902,7 +5936,16 @@ function AddPrinterModal({
                 {t('printers.modal.autoArchiveLabel')}
                 {t('printers.modal.autoArchiveLabel')}
               </label>
               </label>
             </div>
             </div>
-            <div className="flex gap-3 pt-4">
+            <button
+              type="button"
+              onClick={() => setShowDiagnostic(true)}
+              disabled={!form.ip_address.trim()}
+              className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm text-bambu-gray hover:text-white disabled:opacity-40 disabled:cursor-not-allowed border border-bambu-dark-tertiary rounded-lg transition-colors"
+            >
+              <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">
               <Button type="button" variant="secondary" onClick={onClose} className="flex-1">
                 {t('common.cancel')}
                 {t('common.cancel')}
               </Button>
               </Button>
@@ -5914,6 +5957,18 @@ function AddPrinterModal({
         </CardContent>
         </CardContent>
       </Card>
       </Card>
     </div>
     </div>
+    {showDiagnostic && (
+      <ConnectionDiagnosticModal
+        connection={{
+          ip_address: form.ip_address.trim(),
+          serial_number: form.serial_number.trim() || undefined,
+          access_code: form.access_code || undefined,
+        }}
+        printerName={form.name || null}
+        onClose={() => setShowDiagnostic(false)}
+      />
+    )}
+    </>
   );
   );
 }
 }
 
 

+ 51 - 1
frontend/src/pages/SystemInfoPage.tsx

@@ -21,10 +21,12 @@ import {
   Download,
   Download,
   Headphones,
   Headphones,
   FolderOpen,
   FolderOpen,
+  Stethoscope,
 } from 'lucide-react';
 } from 'lucide-react';
-import { api, supportApi } from '../api/client';
+import { api, supportApi, type Printer as PrinterModel } from '../api/client';
 import { Card } from '../components/Card';
 import { Card } from '../components/Card';
 import { LogViewer } from '../components/LogViewer';
 import { LogViewer } from '../components/LogViewer';
+import { ConnectionDiagnosticModal } from '../components/ConnectionDiagnostic';
 import { formatDateTime, type TimeFormat } from '../utils/date';
 import { formatDateTime, type TimeFormat } from '../utils/date';
 
 
 function formatBytes(bytes: number): string {
 function formatBytes(bytes: number): string {
@@ -98,6 +100,7 @@ export function SystemInfoPage() {
   const [bundleError, setBundleError] = useState<string | null>(null);
   const [bundleError, setBundleError] = useState<string | null>(null);
   const [bundleDownloading, setBundleDownloading] = useState(false);
   const [bundleDownloading, setBundleDownloading] = useState(false);
   const [debugToggling, setDebugToggling] = useState(false);
   const [debugToggling, setDebugToggling] = useState(false);
+  const [diagnosticPrinter, setDiagnosticPrinter] = useState<PrinterModel | null>(null);
 
 
   const { data: systemInfo, isLoading, refetch, isFetching } = useQuery({
   const { data: systemInfo, isLoading, refetch, isFetching } = useQuery({
     queryKey: ['systemInfo'],
     queryKey: ['systemInfo'],
@@ -122,6 +125,11 @@ export function SystemInfoPage() {
     queryFn: api.getLibraryStats,
     queryFn: api.getLibraryStats,
   });
   });
 
 
+  const { data: allPrinters } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+  });
+
   const timeFormat: TimeFormat = settings?.time_format || 'system';
   const timeFormat: TimeFormat = settings?.time_format || 'system';
 
 
   const handleToggleDebugLogging = async () => {
   const handleToggleDebugLogging = async () => {
@@ -355,6 +363,40 @@ export function SystemInfoPage() {
         </div>
         </div>
       </Section>
       </Section>
 
 
+      {/* Connection Diagnostic */}
+      <Section title={t('diagnostic.sectionTitle', 'Connection Diagnostic')} icon={Stethoscope}>
+        <p className="text-sm text-bambu-gray mb-4">
+          {t(
+            'diagnostic.sectionDescription',
+            "Check why a printer won't connect or won't print — port reachability, LAN developer mode, Docker network mode, and credentials.",
+          )}
+        </p>
+        {allPrinters && allPrinters.length > 0 ? (
+          <div className="space-y-2">
+            {allPrinters.map((printer) => (
+              <div
+                key={printer.id}
+                className="flex items-center justify-between p-3 bg-bambu-dark rounded-lg"
+              >
+                <div className="min-w-0">
+                  <span className="font-medium text-white">{printer.name}</span>
+                  <span className="text-sm text-bambu-gray ml-2">{printer.ip_address}</span>
+                </div>
+                <button
+                  onClick={() => setDiagnosticPrinter(printer)}
+                  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"
+                >
+                  <Stethoscope className="w-4 h-4" />
+                  {t('diagnostic.runButton', 'Run diagnostic')}
+                </button>
+              </div>
+            ))}
+          </div>
+        ) : (
+          <p className="text-bambu-gray">{t('diagnostic.noPrinters', 'No printers configured.')}</p>
+        )}
+      </Section>
+
       {/* Database Stats */}
       {/* Database Stats */}
       <Section title={t('system.database', 'Database')} icon={Database}>
       <Section title={t('system.database', 'Database')} icon={Database}>
         <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
         <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
@@ -581,6 +623,14 @@ export function SystemInfoPage() {
           />
           />
         </div>
         </div>
       </Section>
       </Section>
+
+      {diagnosticPrinter && (
+        <ConnectionDiagnosticModal
+          printerId={diagnosticPrinter.id}
+          printerName={diagnosticPrinter.name}
+          onClose={() => setDiagnosticPrinter(null)}
+        />
+      )}
     </div>
     </div>
   );
   );
 }
 }

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


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


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


+ 2 - 2
static/index.html

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

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