mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 23:45:40 +02:00
feat(camera): in-app diagnostic for "Connection lost" (#1395 follow-up)
Step 2 of the camera architecture overhaul agreed after #1395. When the camera viewer hits its error state OR before a print at any time, a Diagnose button runs a staged check against the printer and renders the result inline: which stage failed, how long it took, and a translated remediation hint. Cuts off the "user opens a 'camera broken' ticket → ask for support bundle → triage" loop at the user's screen. Backend - New `backend/app/services/camera_diagnose.py` orchestrator with CameraDiagnoseResult / CameraDiagnoseStage dataclasses. - New POST /printers/{id}/camera/diagnose route in camera.py. - Stages: tcp_reachable — TCP socket open to 322 (RTSP) / 6000 (chamber) with 3 s timeout. Distinguishes timeout, refused, and host- unreachable into distinct summary codes so the frontend can show a precise remediation (firewall vs LAN-only off vs wrong IP). first_frame — captures one JPEG end-to-end via the existing capture_camera_frame_bytes pipeline. Auth + RTSP handshake + first keyframe collapse into one stage; the user-facing answer is the same regardless of which sub-layer failed. - Live-stream shortcut: when a viewer is currently watching the camera with a buffered frame < 10 s old, the diagnostic skips the real test and returns live_stream_active_healthy. Opening a fresh socket would kick the live viewer off on single-camera- connection firmwares (the #1348 reconnect-storm trigger), so we trust the real-world evidence instead. - Response surfaces protocol, port, and profile name for support triage — lets us ask "what does your modal say?" instead of "send the support bundle". Frontend - New CameraDiagnoseModal renders one row per stage with green- check / red-X / grey-skipped icons, the per-stage duration in ms, a remediation banner styled by overall status, and a Run again button. - Two entry points: 1. The viewer's error overlay grows a Diagnose button next to Retry. Retry stays the primary action; Diagnose is the escape hatch for users who can't see what's wrong. 2. A stethoscope icon in the viewer's always-visible control bar, between Refresh and Fullscreen. Pre-flight testing ("did my firmware update break the camera?", "is the camera up before I send a print?") doesn't require waiting for the stream to fail first. - Also lifted the previously-hard-coded "Camera unavailable" / "Retry" strings into camera.unavailable / camera.retry so the error UI is fully translated alongside the new keys.
This commit is contained in:
@@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
|
||||
## [0.2.5b1] - Unreleased
|
||||
|
||||
### Added
|
||||
- **Camera: in-app diagnostic for "Connection lost" (#1395 follow-up)** — Second step of the camera architecture overhaul. When the camera viewer hits its error state, a new **Diagnose** button next to **Retry** runs a staged check against the printer and renders the result inline: which stage failed, how long it took, and a translated remediation hint. Cuts off the "user opens a 'camera broken' ticket → wait days → ask for the support bundle → finally figure out it was their reverse proxy / LAN-only toggle / wrong access code" loop at the user's screen. **Backend** ships `backend/app/services/camera_diagnose.py` (orchestrator) and a new `POST /printers/{id}/camera/diagnose` route in `camera.py`. Stages: (1) `tcp_reachable` — opens a TCP socket to the camera port (322 RTSPS / 6000 chamber image) with a 3-second timeout; distinguishes timeout (`tcp_timeout` → "printer not reachable, check IP/network/power") from refused (`tcp_refused` → "camera port closed, check LAN-only and developer mode") from host-unreachable (`tcp_unreachable` → "printer not reachable"). (2) `first_frame` — captures one JPEG end-to-end via the existing `capture_camera_frame_bytes` pipeline (15-second timeout, same code that powers `/camera/snapshot`); auth, RTSP handshake, and first keyframe collapse into one stage because the user-facing answer is the same regardless of which sub-layer failed. **Live-stream shortcut**: when a viewer is currently watching the printer's camera AND the buffered last-frame timestamp is fresher than 10 seconds, the diagnostic skips the real test and returns `live_stream_active_healthy` — opening a fresh socket would kick the live viewer off on single-camera-connection firmwares (the #1348 reconnect-storm trigger), so we trust the real-world evidence instead. Response includes structured metadata for support triage: `protocol` (rtsp / chamber_image), `port`, `profile` (`default` or the model name with an override — currently only `P2S`), per-stage duration in ms, and the machine-readable summary code. **Frontend** adds `CameraDiagnoseModal.tsx` that fires the API call on mount, renders one row per stage with green-check / red-X / grey-skipped icons, and shows the summary remediation message in a bordered banner styled by overall status. The metadata line at the bottom (protocol / port / profile) lets support triage ask "what does your modal say?" instead of "send the support bundle". A **Run again** button re-runs the diagnostic without dismissing the modal. **EmbeddedCameraViewer** error state grows the Diagnose button (kept "Retry" as the primary action; Diagnose is the escape hatch for users who can't see what's wrong). A small stethoscope icon also lives in the viewer's always-visible control bar between **Refresh** and **Fullscreen**, so pre-flight testing ("did my firmware update break the camera?", "is the camera up before I send a print?") doesn't require waiting for the stream to fail first. Also lifted the previously-hard-coded "Camera unavailable" / "Retry" strings into `camera.unavailable` / `camera.retry` so the error UI is properly translated alongside the new keys. **i18n**: 16 new keys (`unavailable`, `retry`, plus `diagnose.{button,modalTitle,running,runFailed,retry,stage.*,summary.*,meta.*}`) translated across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). German "Diagnose" is a real cognate — added to `IDENTICAL_TO_EN_ALLOWED.de` rather than translated to a synthetic. Parity check holds at 4849 leaves per locale. **Tests**: 11 backend unit tests in `test_camera_diagnose.py` cover the live-stream shortcut (skip when fresh, run when stale), the three TCP failure modes (timeout / refused / OSError) → distinct summary codes, the first-frame stage (no-frame and capture-exception cases), the all-OK path, and the result metadata (P2S → P2S profile / rtsp / 322; A1 → default / chamber_image / 6000; X1C → default / rtsp / 322). 1 backend integration test pins the route's response shape end-to-end. 3 frontend tests in `CameraDiagnoseModal.test.tsx` (mounted → API call, failure → translated remediation, Run again → re-call). 5021 backend tests + 1905 frontend tests green; ruff clean; build clean; i18n parity clean.
|
||||
|
||||
### Fixed
|
||||
- **P2S camera: relaxed ffmpeg probe settings so the RTSP stream actually locks (#1395 follow-up, reported by @Tschipel)** — Reporter on a P2S running firmware 01.02.00.00 saw the camera connect for a few seconds and then time out, repeating. P1S on the same install worked fine because P1S uses the chamber-image protocol (port 6000), not RTSP — different code path. The P2S RTSP path was running ffmpeg with `-probesize 32 -analyzeduration 0`, tuned for X1/H2 fast startup. The P2S's slower keyframe pacing means ffmpeg can't lock onto the stream within 32 bytes; its own stderr literally says "Stream #0: not enough frames to estimate rate; consider increasing probesize". After ~2 s ffmpeg gives up, Bambuddy reconnects, the cycle repeats. The naïve "just bump probesize" patch would regress every other RTSP-capable printer, so the fix is also the first step of the camera architecture overhaul: per-model tuning lives in a new `backend/app/services/camera_profiles.py` registry instead of hard-coded module constants. `CameraProfile` dataclass holds the previously-global knobs (`probesize`, `analyzeduration`, `rtsp_reconnect_max`, `rtsp_reconnect_delay`, plus an `extra_ffmpeg_input_args` hook for future per-model flags); `get_camera_profile(model)` returns the model's profile or the default. The default profile preserves the historical X1/H2 fast-startup values verbatim — X1, X1C, X1E, X2D, H2C, H2D, H2D Pro, H2S all see no behaviour change. P2S gets the only override today: `probesize=1_000_000`, `analyzeduration=500_000` — enough room for the slow keyframe without adding multi-second startup latency. Internal SSDP codes (e.g. `N7` → P2S) resolve via an alias map so the camera path works during the early-connect window before the display name is settled. The two `_RTSP_MAX_RECONNECTS` / `_RTSP_RECONNECT_DELAY` module constants are gone in favour of `profile.rtsp_reconnect_max` / `profile.rtsp_reconnect_delay`; same defaults, but now overridable per model. Pattern is intentionally extensible — adding the next quirky model is a config entry in `_PROFILES`, not another global constant. **Tests:** 9 new in `test_camera_profiles.py` cover unknown model → default, `None`/empty → default, default preserves historical values, P2S has relaxed probe, P2S internal code (`N7`) resolves to P2S profile, lookup is case-insensitive, every other RTSP model still uses the default (so the next refactor regression is caught at unit-test time), profile is frozen (immutable). 58 existing camera-related tests still green; 5008 backend tests total green; ruff clean.
|
||||
|
||||
|
||||
@@ -927,6 +927,41 @@ async def test_camera(
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/{printer_id}/camera/diagnose")
|
||||
async def diagnose_camera_route(
|
||||
printer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
|
||||
):
|
||||
"""Run staged diagnostics for a printer's camera path.
|
||||
|
||||
Returns a structured result the frontend renders inline so users can
|
||||
self-diagnose "connection lost" before opening a ticket. See
|
||||
``camera_diagnose`` for stage details and the live-stream shortcut.
|
||||
"""
|
||||
import time
|
||||
|
||||
from backend.app.services.camera_diagnose import diagnose_camera
|
||||
|
||||
printer = await get_printer_or_404(printer_id, db)
|
||||
|
||||
# Look up live-stream evidence so the diagnostic can short-circuit
|
||||
# instead of fighting a viewer for the printer's single camera slot.
|
||||
has_live = is_stream_active(printer_id)
|
||||
last_ts = _last_frame_times.get(printer_id) if has_live else None
|
||||
live_age = (time.time() - last_ts) if (has_live and last_ts) else None
|
||||
|
||||
result = await diagnose_camera(
|
||||
ip_address=printer.ip_address,
|
||||
access_code=printer.access_code,
|
||||
model=printer.model,
|
||||
printer_id=printer_id,
|
||||
has_live_stream=has_live,
|
||||
live_frame_age_seconds=live_age,
|
||||
)
|
||||
return result.to_dict()
|
||||
|
||||
|
||||
@router.get("/{printer_id}/camera/status")
|
||||
async def camera_status(
|
||||
printer_id: int,
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
"""End-to-end camera diagnostic, surfaced via ``POST /printers/{id}/camera/diagnose``.
|
||||
|
||||
Cuts off the "camera broken" support-ticket loop at the user's screen by
|
||||
running the printer-side camera path through staged checks (TCP, end-
|
||||
to-end frame capture) and reporting WHICH stage failed plus a
|
||||
remediation key the frontend can render translated.
|
||||
|
||||
The goal isn't to be a perfect protocol analyser — it's to be the diff
|
||||
between "user opens a ticket with 'connection lost'" and "user sees
|
||||
'Printer not reachable; check IP and LAN-only mode'" before they ever
|
||||
write a message.
|
||||
|
||||
Stages
|
||||
------
|
||||
|
||||
1. **tcp_reachable** — open a TCP socket to the camera port (322 for
|
||||
RTSPS models, 6000 for the chamber-image-protocol A1 / P1 family).
|
||||
Distinguishes "printer down" / "firewall" / "LAN-only off" from
|
||||
stream-content problems.
|
||||
2. **first_frame** — call the existing ``capture_camera_frame_bytes``
|
||||
pipeline (same code that powers /camera/snapshot) and verify at
|
||||
least one JPEG comes back within the model's profile-derived
|
||||
timeout. Combines auth + protocol handshake + first keyframe into
|
||||
one stage because splitting RTSP's ``ffmpeg`` invocation is heavy
|
||||
and the user-facing answer is the same either way: "the camera
|
||||
itself isn't producing frames".
|
||||
|
||||
Shortcut
|
||||
--------
|
||||
|
||||
Most Bambu firmwares allow exactly one concurrent camera connection.
|
||||
Opening a fresh socket while a viewer is attached would kick them off
|
||||
(and trigger the same #1348 reconnect-storm pattern we built the fan-
|
||||
out broadcaster to prevent). When ``is_stream_active`` reports True
|
||||
AND a buffered frame is fresh (last 10 s), we short-circuit the test
|
||||
with ``live_stream_active`` and report success — the user is
|
||||
literally watching the camera right now, no test needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from backend.app.services.camera import (
|
||||
capture_camera_frame_bytes,
|
||||
get_camera_port,
|
||||
is_chamber_image_model,
|
||||
)
|
||||
from backend.app.services.camera_profiles import DEFAULT_PROFILE, get_camera_profile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# How long a live-stream buffered frame stays "fresh enough" to count as
|
||||
# proof that the camera works. Tuned conservatively — if the active
|
||||
# stream hasn't produced a frame in this window, run the real test
|
||||
# instead of trusting a possibly-stale buffer.
|
||||
_LIVE_FRAME_FRESHNESS_SECONDS = 10.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class CameraDiagnoseStage:
|
||||
"""One step of the diagnostic. Status drives the green/red icon
|
||||
the frontend renders next to the stage name."""
|
||||
|
||||
name: str # "tcp_reachable" | "first_frame" | "live_stream_active"
|
||||
status: str # "ok" | "failed" | "skipped"
|
||||
duration_ms: int = 0
|
||||
# Optional machine-readable code for failures so the frontend can
|
||||
# render a stage-specific hint without parsing free-text errors.
|
||||
code: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CameraDiagnoseResult:
|
||||
printer_id: int
|
||||
protocol: str # "rtsp" | "chamber_image"
|
||||
port: int
|
||||
# Whether this model's camera path uses the default profile or has
|
||||
# an override entry in ``camera_profiles._PROFILES``. Useful for
|
||||
# triage: tells us instantly whether the user is on a tuned model.
|
||||
profile: str
|
||||
overall_status: str # "ok" | "failed"
|
||||
stages: list[CameraDiagnoseStage] = field(default_factory=list)
|
||||
# i18n key. Frontend maps to a translated remediation hint.
|
||||
summary_code: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"printer_id": self.printer_id,
|
||||
"protocol": self.protocol,
|
||||
"port": self.port,
|
||||
"profile": self.profile,
|
||||
"overall_status": self.overall_status,
|
||||
"stages": [
|
||||
{"name": s.name, "status": s.status, "duration_ms": s.duration_ms, "code": s.code} for s in self.stages
|
||||
],
|
||||
"summary_code": self.summary_code,
|
||||
}
|
||||
|
||||
|
||||
def _profile_label(model: str | None) -> str:
|
||||
"""Return ``"default"`` or the resolved model name when this model
|
||||
has an override entry in :data:`camera_profiles._PROFILES`."""
|
||||
profile = get_camera_profile(model)
|
||||
if profile is DEFAULT_PROFILE:
|
||||
return "default"
|
||||
# Normalise via the same alias map the lookup uses. If the model
|
||||
# resolves to a profile but the lookup is by alias (e.g. N7 → P2S),
|
||||
# report the canonical display name.
|
||||
from backend.app.services.camera_profiles import _MODEL_ALIASES, _PROFILES
|
||||
|
||||
key = (model or "").upper().strip()
|
||||
key = _MODEL_ALIASES.get(key, key)
|
||||
return key if key in _PROFILES else "default"
|
||||
|
||||
|
||||
async def _check_tcp_reachable(ip_address: str, port: int, timeout: float) -> CameraDiagnoseStage:
|
||||
"""Stage 1 — open a TCP socket to the camera port."""
|
||||
started = time.monotonic()
|
||||
try:
|
||||
_, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(ip_address, port),
|
||||
timeout=timeout,
|
||||
)
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except OSError:
|
||||
pass
|
||||
return CameraDiagnoseStage(
|
||||
name="tcp_reachable",
|
||||
status="ok",
|
||||
duration_ms=int((time.monotonic() - started) * 1000),
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return CameraDiagnoseStage(
|
||||
name="tcp_reachable",
|
||||
status="failed",
|
||||
duration_ms=int((time.monotonic() - started) * 1000),
|
||||
code="tcp_timeout",
|
||||
)
|
||||
except (ConnectionRefusedError, OSError) as exc:
|
||||
# ConnectionRefusedError = printer up, camera port closed (likely
|
||||
# LAN-only off or developer mode off). Other OSError = host
|
||||
# unreachable. We keep these separate codes so the frontend can
|
||||
# surface a precise remediation hint.
|
||||
is_refused = isinstance(exc, ConnectionRefusedError)
|
||||
return CameraDiagnoseStage(
|
||||
name="tcp_reachable",
|
||||
status="failed",
|
||||
duration_ms=int((time.monotonic() - started) * 1000),
|
||||
code="tcp_refused" if is_refused else "tcp_unreachable",
|
||||
)
|
||||
|
||||
|
||||
async def _check_first_frame(
|
||||
ip_address: str,
|
||||
access_code: str,
|
||||
model: str | None,
|
||||
timeout: int,
|
||||
) -> CameraDiagnoseStage:
|
||||
"""Stage 2 — capture one frame end-to-end. Combines auth + protocol
|
||||
handshake + first keyframe; either it works or it doesn't."""
|
||||
started = time.monotonic()
|
||||
try:
|
||||
jpeg = await capture_camera_frame_bytes(
|
||||
ip_address=ip_address,
|
||||
access_code=access_code,
|
||||
model=model,
|
||||
timeout=timeout,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — see camera_profiles.py rationale
|
||||
# capture_camera_frame_bytes can raise from many layers (ffmpeg
|
||||
# spawn, TLS proxy startup, asyncio.open_connection). For the
|
||||
# user-facing answer, any exception during the capture path is
|
||||
# "first frame failed" — drilling down is for the support log.
|
||||
logger.warning("Camera diagnose first-frame capture raised: %s", exc)
|
||||
return CameraDiagnoseStage(
|
||||
name="first_frame",
|
||||
status="failed",
|
||||
duration_ms=int((time.monotonic() - started) * 1000),
|
||||
code="capture_exception",
|
||||
)
|
||||
if jpeg:
|
||||
return CameraDiagnoseStage(
|
||||
name="first_frame",
|
||||
status="ok",
|
||||
duration_ms=int((time.monotonic() - started) * 1000),
|
||||
)
|
||||
return CameraDiagnoseStage(
|
||||
name="first_frame",
|
||||
status="failed",
|
||||
duration_ms=int((time.monotonic() - started) * 1000),
|
||||
code="no_frame",
|
||||
)
|
||||
|
||||
|
||||
def _summary_for_stages(stages: list[CameraDiagnoseStage]) -> str:
|
||||
"""Pick the remediation key from the first failing stage's ``code``,
|
||||
or ``all_ok`` when every stage passed."""
|
||||
for stage in stages:
|
||||
if stage.status != "failed":
|
||||
continue
|
||||
if stage.code == "tcp_timeout":
|
||||
return "printer_unreachable"
|
||||
if stage.code == "tcp_refused":
|
||||
return "camera_port_closed"
|
||||
if stage.code == "tcp_unreachable":
|
||||
return "printer_unreachable"
|
||||
if stage.code in ("no_frame", "capture_exception"):
|
||||
return "no_frame"
|
||||
return "unknown_failure"
|
||||
return "all_ok"
|
||||
|
||||
|
||||
async def diagnose_camera(
|
||||
ip_address: str,
|
||||
access_code: str,
|
||||
model: str | None,
|
||||
printer_id: int,
|
||||
*,
|
||||
has_live_stream: bool = False,
|
||||
live_frame_age_seconds: float | None = None,
|
||||
tcp_timeout: float = 3.0,
|
||||
capture_timeout: int = 15,
|
||||
) -> CameraDiagnoseResult:
|
||||
"""Run the camera diagnostic and return a structured result.
|
||||
|
||||
``has_live_stream`` and ``live_frame_age_seconds`` are looked up
|
||||
by the route handler from the active-stream registry (see the
|
||||
docstring at the top of this file for why). When they indicate a
|
||||
fresh frame is already buffered, the diagnostic short-circuits with
|
||||
a ``live_stream_active`` stage and ``all_ok`` summary — real-world
|
||||
proof of a working camera beats any synthetic test.
|
||||
"""
|
||||
is_chamber = is_chamber_image_model(model)
|
||||
protocol = "chamber_image" if is_chamber else "rtsp"
|
||||
port = get_camera_port(model)
|
||||
|
||||
result = CameraDiagnoseResult(
|
||||
printer_id=printer_id,
|
||||
protocol=protocol,
|
||||
port=port,
|
||||
profile=_profile_label(model),
|
||||
overall_status="ok",
|
||||
stages=[],
|
||||
)
|
||||
|
||||
# Shortcut: the camera is currently streaming with a fresh frame.
|
||||
# Running the real diagnostic here would either kick the live
|
||||
# viewer off (single-camera-connection printers) or block on the
|
||||
# second-socket-refused timeout (#1348). Trust the live evidence.
|
||||
if (
|
||||
has_live_stream
|
||||
and live_frame_age_seconds is not None
|
||||
and 0 <= live_frame_age_seconds < _LIVE_FRAME_FRESHNESS_SECONDS
|
||||
):
|
||||
result.stages.append(
|
||||
CameraDiagnoseStage(
|
||||
name="live_stream_active",
|
||||
status="ok",
|
||||
duration_ms=0,
|
||||
)
|
||||
)
|
||||
result.summary_code = "live_stream_active_healthy"
|
||||
return result
|
||||
|
||||
# Stage 1
|
||||
tcp_stage = await _check_tcp_reachable(ip_address, port, tcp_timeout)
|
||||
result.stages.append(tcp_stage)
|
||||
if tcp_stage.status != "ok":
|
||||
result.overall_status = "failed"
|
||||
# Skip first_frame — without TCP there's no point spawning ffmpeg.
|
||||
result.stages.append(CameraDiagnoseStage(name="first_frame", status="skipped", duration_ms=0))
|
||||
result.summary_code = _summary_for_stages(result.stages)
|
||||
return result
|
||||
|
||||
# Stage 2
|
||||
frame_stage = await _check_first_frame(ip_address, access_code, model, capture_timeout)
|
||||
result.stages.append(frame_stage)
|
||||
if frame_stage.status != "ok":
|
||||
result.overall_status = "failed"
|
||||
result.summary_code = _summary_for_stages(result.stages)
|
||||
return result
|
||||
@@ -190,6 +190,56 @@ class TestCameraAPI:
|
||||
result = response.json()
|
||||
assert result["success"] is False
|
||||
|
||||
# ========================================================================
|
||||
# Camera Diagnose Endpoint (#1395 follow-up)
|
||||
# ========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_camera_diagnose_printer_not_found(self, async_client: AsyncClient):
|
||||
response = await async_client.post("/api/v1/printers/99999/camera/diagnose")
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_camera_diagnose_returns_structured_result(self, async_client: AsyncClient, printer_factory):
|
||||
"""Endpoint returns the per-stage shape the frontend modal renders."""
|
||||
from backend.app.services.camera_diagnose import (
|
||||
CameraDiagnoseResult,
|
||||
CameraDiagnoseStage,
|
||||
)
|
||||
|
||||
printer = await printer_factory()
|
||||
|
||||
fake = CameraDiagnoseResult(
|
||||
printer_id=printer.id,
|
||||
protocol="rtsp",
|
||||
port=322,
|
||||
profile="P2S",
|
||||
overall_status="failed",
|
||||
stages=[
|
||||
CameraDiagnoseStage(name="tcp_reachable", status="ok", duration_ms=12),
|
||||
CameraDiagnoseStage(name="first_frame", status="failed", duration_ms=15123, code="no_frame"),
|
||||
],
|
||||
summary_code="no_frame",
|
||||
)
|
||||
with patch(
|
||||
"backend.app.services.camera_diagnose.diagnose_camera",
|
||||
new_callable=AsyncMock,
|
||||
return_value=fake,
|
||||
):
|
||||
response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/diagnose")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["printer_id"] == printer.id
|
||||
assert body["protocol"] == "rtsp"
|
||||
assert body["profile"] == "P2S"
|
||||
assert body["overall_status"] == "failed"
|
||||
assert body["summary_code"] == "no_frame"
|
||||
assert [s["name"] for s in body["stages"]] == ["tcp_reachable", "first_frame"]
|
||||
assert body["stages"][1]["code"] == "no_frame"
|
||||
|
||||
# ========================================================================
|
||||
# Camera Snapshot Endpoint
|
||||
# ========================================================================
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
"""Unit tests for the staged camera diagnostic.
|
||||
|
||||
Covers the per-stage pass/fail contract that drives the frontend
|
||||
remediation hints. The live-stream shortcut and the failure-to-summary
|
||||
mapping are the load-bearing pieces — both are pinned with explicit
|
||||
tests so future profile/protocol changes don't silently turn
|
||||
"camera_port_closed" into "printer_unreachable".
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.services.camera_diagnose import (
|
||||
_LIVE_FRAME_FRESHNESS_SECONDS,
|
||||
diagnose_camera,
|
||||
)
|
||||
|
||||
|
||||
class TestLiveStreamShortcut:
|
||||
"""If a viewer is currently watching the camera with a fresh frame,
|
||||
diagnose must NOT open a fresh socket — single-camera-connection
|
||||
firmwares would kick the live viewer off. Trust the live evidence.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_test_when_fresh_frame_in_active_stream(self):
|
||||
result = await diagnose_camera(
|
||||
ip_address="192.0.2.1",
|
||||
access_code="x",
|
||||
model="X1C",
|
||||
printer_id=1,
|
||||
has_live_stream=True,
|
||||
live_frame_age_seconds=2.0,
|
||||
)
|
||||
assert result.overall_status == "ok"
|
||||
assert result.summary_code == "live_stream_active_healthy"
|
||||
assert len(result.stages) == 1
|
||||
assert result.stages[0].name == "live_stream_active"
|
||||
assert result.stages[0].status == "ok"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runs_test_when_stale_frame_in_active_stream(self):
|
||||
"""An active stream with a stale buffered frame (e.g. mid-
|
||||
reconnect) shouldn't short-circuit — the stream might be
|
||||
wedged and the user needs the real test."""
|
||||
with patch(
|
||||
"backend.app.services.camera_diagnose.asyncio.open_connection",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=TimeoutError,
|
||||
):
|
||||
result = await diagnose_camera(
|
||||
ip_address="192.0.2.1",
|
||||
access_code="x",
|
||||
model="X1C",
|
||||
printer_id=1,
|
||||
has_live_stream=True,
|
||||
live_frame_age_seconds=_LIVE_FRAME_FRESHNESS_SECONDS + 5,
|
||||
)
|
||||
# No short-circuit — we ran the real check and it failed.
|
||||
assert result.summary_code != "live_stream_active_healthy"
|
||||
assert any(s.name == "tcp_reachable" for s in result.stages)
|
||||
|
||||
|
||||
class TestTcpStage:
|
||||
"""The first stage answers "can we even talk to the printer at all".
|
||||
The three failure modes (timeout / refused / unreachable) map to
|
||||
distinct user-facing remediation hints, so the codes must round-
|
||||
trip correctly through ``_summary_for_stages``."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_maps_to_printer_unreachable(self):
|
||||
with patch(
|
||||
"backend.app.services.camera_diagnose.asyncio.open_connection",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=TimeoutError,
|
||||
):
|
||||
result = await diagnose_camera(
|
||||
ip_address="192.0.2.99",
|
||||
access_code="x",
|
||||
model="P2S",
|
||||
printer_id=1,
|
||||
)
|
||||
assert result.overall_status == "failed"
|
||||
assert result.summary_code == "printer_unreachable"
|
||||
first = result.stages[0]
|
||||
assert first.name == "tcp_reachable"
|
||||
assert first.code == "tcp_timeout"
|
||||
# Second stage was skipped — no point spawning ffmpeg with no socket.
|
||||
assert result.stages[1].name == "first_frame"
|
||||
assert result.stages[1].status == "skipped"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_refused_maps_to_camera_port_closed(self):
|
||||
"""ConnectionRefusedError = printer up, port closed. Common
|
||||
cause: LAN-only mode off, or developer mode off. The user
|
||||
sees a specific remediation hint, not the generic
|
||||
'unreachable' message."""
|
||||
with patch(
|
||||
"backend.app.services.camera_diagnose.asyncio.open_connection",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=ConnectionRefusedError(),
|
||||
):
|
||||
result = await diagnose_camera(
|
||||
ip_address="192.0.2.1",
|
||||
access_code="x",
|
||||
model="P2S",
|
||||
printer_id=1,
|
||||
)
|
||||
assert result.summary_code == "camera_port_closed"
|
||||
assert result.stages[0].code == "tcp_refused"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oserror_maps_to_printer_unreachable(self):
|
||||
"""Generic OSError (no-route-to-host etc.) lumps under
|
||||
'printer_unreachable' — same remediation as timeout."""
|
||||
with patch(
|
||||
"backend.app.services.camera_diagnose.asyncio.open_connection",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=OSError("No route to host"),
|
||||
):
|
||||
result = await diagnose_camera(
|
||||
ip_address="192.0.2.1",
|
||||
access_code="x",
|
||||
model="P2S",
|
||||
printer_id=1,
|
||||
)
|
||||
assert result.summary_code == "printer_unreachable"
|
||||
assert result.stages[0].code == "tcp_unreachable"
|
||||
|
||||
|
||||
class TestFirstFrameStage:
|
||||
"""The second stage answers "is the camera actually producing
|
||||
frames". If TCP passes but no frame comes back, the answer is the
|
||||
same regardless of which sub-layer failed (auth, RTSP handshake,
|
||||
keyframe probe): the user can't see the camera."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_frame_maps_to_no_frame_summary(self):
|
||||
async def _tcp_ok(*_a, **_kw):
|
||||
writer = AsyncMock()
|
||||
return AsyncMock(), writer
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.app.services.camera_diagnose.asyncio.open_connection",
|
||||
new=_tcp_ok,
|
||||
),
|
||||
patch(
|
||||
"backend.app.services.camera_diagnose.capture_camera_frame_bytes",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
result = await diagnose_camera(
|
||||
ip_address="192.0.2.1",
|
||||
access_code="x",
|
||||
model="P2S",
|
||||
printer_id=1,
|
||||
)
|
||||
assert result.overall_status == "failed"
|
||||
assert result.summary_code == "no_frame"
|
||||
assert result.stages[0].status == "ok"
|
||||
assert result.stages[1].name == "first_frame"
|
||||
assert result.stages[1].code == "no_frame"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capture_exception_maps_to_no_frame_summary(self):
|
||||
"""ffmpeg crash / TLS proxy startup failure / etc. — all the
|
||||
sub-layer exceptions surface as 'no_frame' for the user, with
|
||||
a distinct ``capture_exception`` code in the stage so the
|
||||
support log retains the distinction."""
|
||||
|
||||
async def _tcp_ok(*_a, **_kw):
|
||||
writer = AsyncMock()
|
||||
return AsyncMock(), writer
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.app.services.camera_diagnose.asyncio.open_connection",
|
||||
new=_tcp_ok,
|
||||
),
|
||||
patch(
|
||||
"backend.app.services.camera_diagnose.capture_camera_frame_bytes",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("ffmpeg died"),
|
||||
),
|
||||
):
|
||||
result = await diagnose_camera(
|
||||
ip_address="192.0.2.1",
|
||||
access_code="x",
|
||||
model="P2S",
|
||||
printer_id=1,
|
||||
)
|
||||
assert result.summary_code == "no_frame"
|
||||
assert result.stages[1].code == "capture_exception"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_success_path(self):
|
||||
async def _tcp_ok(*_a, **_kw):
|
||||
writer = AsyncMock()
|
||||
return AsyncMock(), writer
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.app.services.camera_diagnose.asyncio.open_connection",
|
||||
new=_tcp_ok,
|
||||
),
|
||||
patch(
|
||||
"backend.app.services.camera_diagnose.capture_camera_frame_bytes",
|
||||
new_callable=AsyncMock,
|
||||
return_value=b"\xff\xd8\xff\xd9", # tiny valid-looking JPEG
|
||||
),
|
||||
):
|
||||
result = await diagnose_camera(
|
||||
ip_address="192.0.2.1",
|
||||
access_code="x",
|
||||
model="P2S",
|
||||
printer_id=1,
|
||||
)
|
||||
assert result.overall_status == "ok"
|
||||
assert result.summary_code == "all_ok"
|
||||
assert all(s.status == "ok" for s in result.stages)
|
||||
|
||||
|
||||
class TestResultMetadata:
|
||||
"""Surface fields the support triage relies on — protocol, port,
|
||||
profile name. The frontend renders these so we can ask the user
|
||||
'is your profile 'P2S' or 'default'?' over a screenshot rather
|
||||
than asking for the support bundle."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_p2s_reports_p2s_profile_and_rtsp_protocol(self):
|
||||
with patch(
|
||||
"backend.app.services.camera_diagnose.asyncio.open_connection",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=TimeoutError,
|
||||
):
|
||||
result = await diagnose_camera(
|
||||
ip_address="192.0.2.1",
|
||||
access_code="x",
|
||||
model="P2S",
|
||||
printer_id=1,
|
||||
)
|
||||
assert result.protocol == "rtsp"
|
||||
assert result.profile == "P2S"
|
||||
assert result.port == 322
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a1_reports_default_profile_and_chamber_protocol(self):
|
||||
with patch(
|
||||
"backend.app.services.camera_diagnose.asyncio.open_connection",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=TimeoutError,
|
||||
):
|
||||
result = await diagnose_camera(
|
||||
ip_address="192.0.2.1",
|
||||
access_code="x",
|
||||
model="A1",
|
||||
printer_id=1,
|
||||
)
|
||||
assert result.protocol == "chamber_image"
|
||||
assert result.profile == "default"
|
||||
assert result.port == 6000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_x1c_reports_default_profile_and_rtsp(self):
|
||||
with patch(
|
||||
"backend.app.services.camera_diagnose.asyncio.open_connection",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=TimeoutError,
|
||||
):
|
||||
result = await diagnose_camera(
|
||||
ip_address="192.0.2.1",
|
||||
access_code="x",
|
||||
model="X1C",
|
||||
printer_id=1,
|
||||
)
|
||||
assert result.protocol == "rtsp"
|
||||
assert result.profile == "default"
|
||||
assert result.port == 322
|
||||
@@ -164,6 +164,7 @@ const DE_COGNATES = [
|
||||
'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
|
||||
'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
|
||||
'China', 'Proxy', 'Start',
|
||||
'Diagnose', // DE: same spelling/meaning as EN — camera diagnostic button label
|
||||
];
|
||||
|
||||
// French cognates — many UI labels overlap with English exactly.
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Tests for the camera diagnostic modal (#1395 follow-up).
|
||||
*
|
||||
* Covers the three observable behaviours that matter for user-facing
|
||||
* triage: the modal kicks off the diagnostic on mount, renders per-
|
||||
* stage results when the API replies, and maps the summary code to a
|
||||
* translated remediation hint. Each test mocks the API client so the
|
||||
* suite never actually opens a socket.
|
||||
*/
|
||||
|
||||
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 { CameraDiagnoseModal } from '../../components/CameraDiagnoseModal';
|
||||
import { api, type CameraDiagnoseResult } from '../../api/client';
|
||||
|
||||
function renderModal() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<CameraDiagnoseModal printerId={1} printerName="Test P2S" onClose={onClose} />
|
||||
</I18nextProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { onClose };
|
||||
}
|
||||
|
||||
describe('CameraDiagnoseModal', () => {
|
||||
it('runs the diagnostic on mount and shows per-stage results', async () => {
|
||||
const okResult: CameraDiagnoseResult = {
|
||||
printer_id: 1,
|
||||
protocol: 'rtsp',
|
||||
port: 322,
|
||||
profile: 'P2S',
|
||||
overall_status: 'ok',
|
||||
stages: [
|
||||
{ name: 'tcp_reachable', status: 'ok', duration_ms: 12, code: null },
|
||||
{ name: 'first_frame', status: 'ok', duration_ms: 1230, code: null },
|
||||
],
|
||||
summary_code: 'all_ok',
|
||||
};
|
||||
const spy = vi.spyOn(api, 'diagnoseCamera').mockResolvedValue(okResult);
|
||||
|
||||
renderModal();
|
||||
|
||||
// Mounted → API called once
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
|
||||
|
||||
// Stage names render via i18n
|
||||
expect(await screen.findByText(/Network reachability/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Frame capture/i)).toBeInTheDocument();
|
||||
|
||||
// Per-stage duration is shown for support triage
|
||||
expect(screen.getByText(/12 ms/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1230 ms/i)).toBeInTheDocument();
|
||||
|
||||
// Summary remediation message is rendered translated
|
||||
expect(screen.getByText(/Camera is working/i)).toBeInTheDocument();
|
||||
|
||||
// Metadata for support triage
|
||||
expect(screen.getByText('rtsp')).toBeInTheDocument();
|
||||
expect(screen.getByText('322')).toBeInTheDocument();
|
||||
expect(screen.getByText('P2S')).toBeInTheDocument();
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('maps a failure summary code to a translated remediation hint', async () => {
|
||||
const failedResult: CameraDiagnoseResult = {
|
||||
printer_id: 1,
|
||||
protocol: 'rtsp',
|
||||
port: 322,
|
||||
profile: 'P2S',
|
||||
overall_status: 'failed',
|
||||
stages: [
|
||||
{ name: 'tcp_reachable', status: 'failed', duration_ms: 3001, code: 'tcp_timeout' },
|
||||
{ name: 'first_frame', status: 'skipped', duration_ms: 0, code: null },
|
||||
],
|
||||
summary_code: 'printer_unreachable',
|
||||
};
|
||||
const spy = vi.spyOn(api, 'diagnoseCamera').mockResolvedValue(failedResult);
|
||||
|
||||
renderModal();
|
||||
|
||||
// The remediation hint for printer_unreachable mentions IP / network /
|
||||
// power — the user-facing fix-it instructions, not the raw summary code.
|
||||
expect(await screen.findByText(/IP address/i)).toBeInTheDocument();
|
||||
|
||||
// The machine-readable stage code is also surfaced (small font) for
|
||||
// support triage so users can paste it into a ticket.
|
||||
expect(screen.getByText('tcp_timeout')).toBeInTheDocument();
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('re-runs the diagnostic when the user clicks Run again', async () => {
|
||||
const okResult: CameraDiagnoseResult = {
|
||||
printer_id: 1,
|
||||
protocol: 'rtsp',
|
||||
port: 322,
|
||||
profile: 'P2S',
|
||||
overall_status: 'ok',
|
||||
stages: [{ name: 'tcp_reachable', status: 'ok', duration_ms: 12, code: null }],
|
||||
summary_code: 'all_ok',
|
||||
};
|
||||
const spy = vi.spyOn(api, 'diagnoseCamera').mockResolvedValue(okResult);
|
||||
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
|
||||
|
||||
fireEvent.click(screen.getByText(/Run again/i));
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledTimes(2));
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -159,6 +159,30 @@ async function request<T>(
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// Camera diagnostic result (#1395 follow-up). Returned by
|
||||
// POST /printers/{id}/camera/diagnose; the frontend modal renders one
|
||||
// row per stage and looks up the summary code in i18n for the user-
|
||||
// facing remediation hint.
|
||||
export interface CameraDiagnoseStage {
|
||||
name: 'tcp_reachable' | 'first_frame' | 'live_stream_active';
|
||||
status: 'ok' | 'failed' | 'skipped';
|
||||
duration_ms: number;
|
||||
code: string | null;
|
||||
}
|
||||
|
||||
export interface CameraDiagnoseResult {
|
||||
printer_id: number;
|
||||
protocol: 'rtsp' | 'chamber_image';
|
||||
port: number;
|
||||
// 'default' = historical X1/H2 tuning. Anything else = this model has
|
||||
// an override entry in backend/app/services/camera_profiles.py.
|
||||
profile: string;
|
||||
overall_status: 'ok' | 'failed';
|
||||
stages: CameraDiagnoseStage[];
|
||||
// i18n key under `camera.diagnose.summary.*`.
|
||||
summary_code: string;
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -4976,6 +5000,8 @@ export const api = {
|
||||
request<{ success: boolean; message?: string; error?: string }>(`/printers/${printerId}/camera/test`),
|
||||
getCameraStatus: (printerId: number) =>
|
||||
request<{ active: boolean; stalled: boolean }>(`/printers/${printerId}/camera/status`),
|
||||
diagnoseCamera: (printerId: number) =>
|
||||
request<CameraDiagnoseResult>(`/printers/${printerId}/camera/diagnose`, { method: 'POST' }),
|
||||
|
||||
// Plate Detection - Multi-reference calibration (stores up to 5 references per printer)
|
||||
checkPlateEmpty: (printerId: number, options?: { useExternal?: boolean; includeDebugImage?: boolean }) => {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Stethoscope, CheckCircle2, XCircle, MinusCircle, Loader2 } from 'lucide-react';
|
||||
import { api, type CameraDiagnoseResult, type CameraDiagnoseStage } from '../api/client';
|
||||
|
||||
interface CameraDiagnoseModalProps {
|
||||
printerId: number;
|
||||
printerName: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function StageIcon({ status }: { status: CameraDiagnoseStage['status'] }) {
|
||||
if (status === 'ok') return <CheckCircle2 className="w-5 h-5 text-bambu-green flex-shrink-0" />;
|
||||
if (status === 'failed') return <XCircle className="w-5 h-5 text-red-400 flex-shrink-0" />;
|
||||
return <MinusCircle className="w-5 h-5 text-bambu-gray flex-shrink-0" />;
|
||||
}
|
||||
|
||||
export function CameraDiagnoseModal({ printerId, printerName, onClose }: CameraDiagnoseModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Kick the diagnostic off as soon as the modal mounts. There's no
|
||||
// "Start" button — opening the modal IS the test. The mutation
|
||||
// shape is right here: we want a one-shot POST with isPending /
|
||||
// data / error, not a cached query.
|
||||
const diagnose = useMutation({
|
||||
mutationFn: () => api.diagnoseCamera(printerId),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
diagnose.mutate();
|
||||
// Intentionally only on mount — re-running needs the user to click "Retry".
|
||||
// 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 CameraDiagnoseResult | 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"
|
||||
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('camera.diagnose.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">
|
||||
{diagnose.isPending && (
|
||||
<div className="flex items-center gap-2 text-bambu-gray">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>{t('camera.diagnose.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('camera.diagnose.runFailed', { error: (diagnose.error as Error).message })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<>
|
||||
{/* Per-stage results */}
|
||||
<ol className="space-y-2">
|
||||
{result.stages.map((stage) => (
|
||||
<li
|
||||
key={stage.name}
|
||||
className="flex items-center gap-3 bg-bambu-dark rounded-lg px-4 py-2.5"
|
||||
>
|
||||
<StageIcon status={stage.status} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-white">
|
||||
{t(`camera.diagnose.stage.${stage.name}`)}
|
||||
</div>
|
||||
{stage.code && (
|
||||
<div className="text-xs text-bambu-gray font-mono">{stage.code}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-bambu-gray tabular-nums flex-shrink-0">
|
||||
{stage.duration_ms} ms
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{/* Summary + remediation */}
|
||||
<div
|
||||
className={
|
||||
result.overall_status === 'ok'
|
||||
? 'rounded-lg bg-bambu-green/10 border border-bambu-green/30 px-4 py-3 text-sm text-bambu-green'
|
||||
: 'rounded-lg bg-red-500/10 border border-red-500/30 px-4 py-3 text-sm text-red-300'
|
||||
}
|
||||
>
|
||||
{t(`camera.diagnose.summary.${result.summary_code}`, {
|
||||
defaultValue: t('camera.diagnose.summary.unknown_failure'),
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Metadata for support triage */}
|
||||
<div className="text-xs text-bambu-gray space-y-0.5">
|
||||
<div>
|
||||
<span className="text-bambu-gray/60">{t('camera.diagnose.meta.protocol')}: </span>
|
||||
<span className="font-mono">{result.protocol}</span>
|
||||
{' • '}
|
||||
<span className="text-bambu-gray/60">{t('camera.diagnose.meta.port')}: </span>
|
||||
<span className="font-mono">{result.port}</span>
|
||||
{' • '}
|
||||
<span className="text-bambu-gray/60">{t('camera.diagnose.meta.profile')}: </span>
|
||||
<span className="font-mono">{result.profile}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</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('camera.diagnose.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>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, RefreshCw, AlertTriangle, Maximize2, Minimize2, GripVertical, WifiOff, ZoomIn, ZoomOut, Fullscreen, Minimize } from 'lucide-react';
|
||||
import { X, RefreshCw, AlertTriangle, Maximize2, Minimize2, GripVertical, WifiOff, ZoomIn, ZoomOut, Fullscreen, Minimize, Stethoscope } from 'lucide-react';
|
||||
import { api, getAuthToken, withStreamToken } from '../api/client';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { ChamberLight } from './icons/ChamberLight';
|
||||
import { SkipObjectsModal, SkipObjectsIcon } from './SkipObjectsModal';
|
||||
import { CameraDiagnoseModal } from './CameraDiagnoseModal';
|
||||
|
||||
interface EmbeddedCameraViewerProps {
|
||||
printerId: number;
|
||||
@@ -98,6 +99,10 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
|
||||
const stallCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);
|
||||
// Modal opens from the error-state "Diagnose" button when the user
|
||||
// hits "Camera unavailable" — saves a round trip through "open a
|
||||
// ticket → wait for response → check setting". See #1395 follow-up.
|
||||
const [showDiagnoseModal, setShowDiagnoseModal] = useState(false);
|
||||
|
||||
// Fetch printer info
|
||||
const { data: printer } = useQuery({
|
||||
@@ -605,6 +610,13 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 text-bambu-gray ${streamLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDiagnoseModal(true)}
|
||||
className="p-1 hover:bg-bambu-dark-tertiary rounded"
|
||||
title={t('camera.diagnose.button')}
|
||||
>
|
||||
<Stethoscope className="w-3.5 h-3.5 text-bambu-gray" />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="p-1 hover:bg-bambu-dark-tertiary rounded"
|
||||
@@ -669,13 +681,21 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black z-10">
|
||||
<div className="text-center p-2">
|
||||
<AlertTriangle className="w-6 h-6 text-orange-400 mx-auto mb-2" />
|
||||
<p className="text-xs text-bambu-gray mb-2">Camera unavailable</p>
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="px-2 py-1 text-xs bg-bambu-green text-white rounded hover:bg-bambu-green/80"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
<p className="text-xs text-bambu-gray mb-2">{t('camera.unavailable')}</p>
|
||||
<div className="flex gap-2 justify-center">
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="px-2 py-1 text-xs bg-bambu-green text-white rounded hover:bg-bambu-green/80"
|
||||
>
|
||||
{t('camera.retry')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDiagnoseModal(true)}
|
||||
className="px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary text-bambu-gray hover:text-white rounded transition-colors"
|
||||
>
|
||||
{t('camera.diagnose.button')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -748,6 +768,14 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
|
||||
isOpen={showSkipObjectsModal}
|
||||
onClose={() => setShowSkipObjectsModal(false)}
|
||||
/>
|
||||
{/* Camera diagnostic modal — opens from the error-state Diagnose button (#1395 follow-up) */}
|
||||
{showDiagnoseModal && (
|
||||
<CameraDiagnoseModal
|
||||
printerId={printerId}
|
||||
printerName={printer?.name || null}
|
||||
onClose={() => setShowDiagnoseModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2638,6 +2638,32 @@ export default {
|
||||
startRecording: 'Aufnahme starten',
|
||||
stopRecording: 'Aufnahme stoppen',
|
||||
chamberLight: 'Kammerbeleuchtung umschalten',
|
||||
unavailable: 'Kamera nicht verfügbar',
|
||||
diagnose: {
|
||||
button: 'Diagnose',
|
||||
modalTitle: 'Kamera-Diagnose',
|
||||
running: 'Diagnose läuft...',
|
||||
runFailed: 'Diagnose konnte nicht ausgeführt werden: {{error}}',
|
||||
retry: 'Erneut ausführen',
|
||||
stage: {
|
||||
tcp_reachable: 'Netzwerk-Erreichbarkeit',
|
||||
first_frame: 'Bilderfassung',
|
||||
live_stream_active: 'Live-Stream aktiv',
|
||||
},
|
||||
summary: {
|
||||
all_ok: 'Kamera funktioniert. Die Diagnose hat alle Phasen erfolgreich abgeschlossen.',
|
||||
live_stream_active_healthy: 'Kamera streamt gerade mit aktuellen Bildern — kein Test nötig.',
|
||||
printer_unreachable: 'Drucker ist nicht erreichbar. Prüfe IP-Adresse, Netzwerkverbindung und ob der Drucker eingeschaltet ist.',
|
||||
camera_port_closed: 'Drucker ist erreichbar, aber der Kamera-Port ist geschlossen. Stelle sicher, dass LAN-Modus und Entwicklermodus in den Druckereinstellungen aktiviert sind.',
|
||||
no_frame: 'Verbindung zur Kamera hergestellt, aber keine Bilder empfangen. Versuche es erneut oder prüfe, ob die Kamera in den Druckereinstellungen aktiviert ist.',
|
||||
unknown_failure: 'Kamera-Diagnose aus unbekanntem Grund fehlgeschlagen. Prüfe das Support-Log für Details.',
|
||||
},
|
||||
meta: {
|
||||
protocol: 'Protokoll',
|
||||
port: 'Port',
|
||||
profile: 'Profil',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Groups management
|
||||
|
||||
@@ -2641,6 +2641,32 @@ export default {
|
||||
startRecording: 'Start Recording',
|
||||
stopRecording: 'Stop Recording',
|
||||
chamberLight: 'Toggle chamber light',
|
||||
unavailable: 'Camera unavailable',
|
||||
diagnose: {
|
||||
button: 'Diagnose',
|
||||
modalTitle: 'Camera diagnostic',
|
||||
running: 'Running diagnostic...',
|
||||
runFailed: 'Diagnostic could not run: {{error}}',
|
||||
retry: 'Run again',
|
||||
stage: {
|
||||
tcp_reachable: 'Network reachability',
|
||||
first_frame: 'Frame capture',
|
||||
live_stream_active: 'Live stream active',
|
||||
},
|
||||
summary: {
|
||||
all_ok: 'Camera is working. The diagnostic completed all stages successfully.',
|
||||
live_stream_active_healthy: 'Camera is currently streaming with recent frames — no test needed.',
|
||||
printer_unreachable: 'Printer is not reachable. Check the IP address, network connection, and that the printer is powered on.',
|
||||
camera_port_closed: 'Printer is reachable but the camera port is closed. Make sure LAN-only mode and Developer Mode are enabled in the printer settings.',
|
||||
no_frame: 'Connected to the camera but no frames were received. Try again, or check that the camera is enabled in the printer settings.',
|
||||
unknown_failure: 'Camera diagnostic failed for an unknown reason. Check the support log for details.',
|
||||
},
|
||||
meta: {
|
||||
protocol: 'Protocol',
|
||||
port: 'Port',
|
||||
profile: 'Profile',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Groups management
|
||||
|
||||
@@ -2627,6 +2627,32 @@ export default {
|
||||
startRecording: 'Démarrer l\'enregistrement',
|
||||
stopRecording: 'Arrêter l\'enregistrement',
|
||||
chamberLight: 'Basculer lumière chambre',
|
||||
unavailable: 'Caméra indisponible',
|
||||
diagnose: {
|
||||
button: 'Diagnostic',
|
||||
modalTitle: 'Diagnostic de la caméra',
|
||||
running: 'Diagnostic en cours...',
|
||||
runFailed: 'Impossible d\'exécuter le diagnostic : {{error}}',
|
||||
retry: 'Relancer',
|
||||
stage: {
|
||||
tcp_reachable: 'Accessibilité réseau',
|
||||
first_frame: 'Capture d\'image',
|
||||
live_stream_active: 'Flux en direct actif',
|
||||
},
|
||||
summary: {
|
||||
all_ok: 'La caméra fonctionne. Toutes les étapes du diagnostic ont réussi.',
|
||||
live_stream_active_healthy: 'La caméra diffuse actuellement avec des images récentes — aucun test nécessaire.',
|
||||
printer_unreachable: 'L\'imprimante n\'est pas joignable. Vérifiez l\'adresse IP, la connexion réseau et que l\'imprimante est allumée.',
|
||||
camera_port_closed: 'L\'imprimante répond mais le port de la caméra est fermé. Assurez-vous que le mode LAN et le mode développeur sont activés dans les paramètres de l\'imprimante.',
|
||||
no_frame: 'Connexion à la caméra établie mais aucune image reçue. Réessayez ou vérifiez que la caméra est activée dans les paramètres de l\'imprimante.',
|
||||
unknown_failure: 'Le diagnostic de la caméra a échoué pour une raison inconnue. Consultez le journal de support pour plus de détails.',
|
||||
},
|
||||
meta: {
|
||||
protocol: 'Protocole',
|
||||
port: 'Port',
|
||||
profile: 'Profil',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Groups management
|
||||
|
||||
@@ -2626,6 +2626,32 @@ export default {
|
||||
startRecording: 'Avvia registrazione',
|
||||
stopRecording: 'Ferma registrazione',
|
||||
chamberLight: 'Accendi/Spegni luce camera',
|
||||
unavailable: 'Telecamera non disponibile',
|
||||
diagnose: {
|
||||
button: 'Diagnostica',
|
||||
modalTitle: 'Diagnostica telecamera',
|
||||
running: 'Diagnostica in corso...',
|
||||
runFailed: 'Impossibile eseguire la diagnostica: {{error}}',
|
||||
retry: 'Riesegui',
|
||||
stage: {
|
||||
tcp_reachable: 'Raggiungibilità di rete',
|
||||
first_frame: 'Acquisizione frame',
|
||||
live_stream_active: 'Stream live attivo',
|
||||
},
|
||||
summary: {
|
||||
all_ok: 'La telecamera funziona. La diagnostica ha completato tutte le fasi con successo.',
|
||||
live_stream_active_healthy: 'La telecamera sta trasmettendo con frame recenti — nessun test necessario.',
|
||||
printer_unreachable: 'La stampante non è raggiungibile. Verifica l\'indirizzo IP, la connessione di rete e che la stampante sia accesa.',
|
||||
camera_port_closed: 'La stampante risponde ma la porta della telecamera è chiusa. Assicurati che la modalità LAN e la modalità sviluppatore siano attive nelle impostazioni della stampante.',
|
||||
no_frame: 'Connessione alla telecamera riuscita ma nessun frame ricevuto. Riprova o verifica che la telecamera sia abilitata nelle impostazioni della stampante.',
|
||||
unknown_failure: 'Diagnostica della telecamera fallita per motivo sconosciuto. Controlla il log di supporto per i dettagli.',
|
||||
},
|
||||
meta: {
|
||||
protocol: 'Protocollo',
|
||||
port: 'Porta',
|
||||
profile: 'Profilo',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Groups management
|
||||
|
||||
@@ -2638,6 +2638,32 @@ export default {
|
||||
startRecording: '録画開始',
|
||||
stopRecording: '録画停止',
|
||||
chamberLight: 'チャンバーライト切替',
|
||||
unavailable: 'カメラは利用できません',
|
||||
diagnose: {
|
||||
button: '診断',
|
||||
modalTitle: 'カメラ診断',
|
||||
running: '診断を実行中...',
|
||||
runFailed: '診断を実行できませんでした: {{error}}',
|
||||
retry: '再実行',
|
||||
stage: {
|
||||
tcp_reachable: 'ネットワーク到達性',
|
||||
first_frame: 'フレーム取得',
|
||||
live_stream_active: 'ライブストリーム動作中',
|
||||
},
|
||||
summary: {
|
||||
all_ok: 'カメラは正常に動作しています。診断のすべての段階が成功しました。',
|
||||
live_stream_active_healthy: 'カメラは現在新しいフレームを配信しています — テストは不要です。',
|
||||
printer_unreachable: 'プリンターに到達できません。IPアドレス、ネットワーク接続、プリンターの電源が入っていることを確認してください。',
|
||||
camera_port_closed: 'プリンターには到達できますが、カメラポートが閉じています。プリンター設定でLANのみモードと開発者モードが有効になっていることを確認してください。',
|
||||
no_frame: 'カメラに接続できましたがフレームを受信できませんでした。再試行するか、プリンター設定でカメラが有効になっていることを確認してください。',
|
||||
unknown_failure: 'カメラ診断が不明な理由で失敗しました。詳細はサポートログを確認してください。',
|
||||
},
|
||||
meta: {
|
||||
protocol: 'プロトコル',
|
||||
port: 'ポート',
|
||||
profile: 'プロファイル',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Groups management
|
||||
|
||||
@@ -2626,6 +2626,32 @@ export default {
|
||||
startRecording: 'Iniciar gravação',
|
||||
stopRecording: 'Parar gravação',
|
||||
chamberLight: 'Alternar luz da câmara',
|
||||
unavailable: 'Câmera indisponível',
|
||||
diagnose: {
|
||||
button: 'Diagnóstico',
|
||||
modalTitle: 'Diagnóstico da câmera',
|
||||
running: 'Executando diagnóstico...',
|
||||
runFailed: 'Não foi possível executar o diagnóstico: {{error}}',
|
||||
retry: 'Executar novamente',
|
||||
stage: {
|
||||
tcp_reachable: 'Conectividade de rede',
|
||||
first_frame: 'Captura de frame',
|
||||
live_stream_active: 'Transmissão ao vivo ativa',
|
||||
},
|
||||
summary: {
|
||||
all_ok: 'A câmera está funcionando. O diagnóstico concluiu todas as etapas com sucesso.',
|
||||
live_stream_active_healthy: 'A câmera está transmitindo com frames recentes — nenhum teste necessário.',
|
||||
printer_unreachable: 'Impressora inacessível. Verifique o endereço IP, a conexão de rede e se a impressora está ligada.',
|
||||
camera_port_closed: 'A impressora responde mas a porta da câmera está fechada. Verifique se o modo LAN e o modo desenvolvedor estão ativados nas configurações da impressora.',
|
||||
no_frame: 'Conexão com a câmera estabelecida mas nenhum frame recebido. Tente novamente ou verifique se a câmera está habilitada nas configurações da impressora.',
|
||||
unknown_failure: 'Diagnóstico da câmera falhou por motivo desconhecido. Consulte o log de suporte para mais detalhes.',
|
||||
},
|
||||
meta: {
|
||||
protocol: 'Protocolo',
|
||||
port: 'Porta',
|
||||
profile: 'Perfil',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Groups management
|
||||
|
||||
@@ -2626,6 +2626,32 @@ export default {
|
||||
startRecording: '开始录制',
|
||||
stopRecording: '停止录制',
|
||||
chamberLight: '切换腔室灯',
|
||||
unavailable: '摄像头不可用',
|
||||
diagnose: {
|
||||
button: '诊断',
|
||||
modalTitle: '摄像头诊断',
|
||||
running: '正在运行诊断...',
|
||||
runFailed: '无法运行诊断: {{error}}',
|
||||
retry: '重新运行',
|
||||
stage: {
|
||||
tcp_reachable: '网络可达性',
|
||||
first_frame: '画面捕获',
|
||||
live_stream_active: '直播流正在进行',
|
||||
},
|
||||
summary: {
|
||||
all_ok: '摄像头工作正常。诊断已成功完成所有阶段。',
|
||||
live_stream_active_healthy: '摄像头正在传输最新画面 — 无需测试。',
|
||||
printer_unreachable: '无法访问打印机。请检查 IP 地址、网络连接以及打印机是否已开机。',
|
||||
camera_port_closed: '打印机可访问,但摄像头端口已关闭。请确保打印机设置中已启用仅 LAN 模式和开发者模式。',
|
||||
no_frame: '已连接到摄像头,但未收到画面。请重试或检查打印机设置中摄像头是否已启用。',
|
||||
unknown_failure: '摄像头诊断因未知原因失败。请查看支持日志了解详情。',
|
||||
},
|
||||
meta: {
|
||||
protocol: '协议',
|
||||
port: '端口',
|
||||
profile: '配置',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Groups management
|
||||
|
||||
@@ -2626,6 +2626,32 @@ export default {
|
||||
startRecording: '開始錄製',
|
||||
stopRecording: '停止錄製',
|
||||
chamberLight: '切換腔室燈',
|
||||
unavailable: '攝影機無法使用',
|
||||
diagnose: {
|
||||
button: '診斷',
|
||||
modalTitle: '攝影機診斷',
|
||||
running: '正在執行診斷...',
|
||||
runFailed: '無法執行診斷: {{error}}',
|
||||
retry: '重新執行',
|
||||
stage: {
|
||||
tcp_reachable: '網路可達性',
|
||||
first_frame: '畫面擷取',
|
||||
live_stream_active: '直播串流進行中',
|
||||
},
|
||||
summary: {
|
||||
all_ok: '攝影機運作正常。診斷已成功完成所有階段。',
|
||||
live_stream_active_healthy: '攝影機正在傳輸最新畫面 — 無需測試。',
|
||||
printer_unreachable: '無法存取印表機。請檢查 IP 位址、網路連線以及印表機是否已開機。',
|
||||
camera_port_closed: '印表機可存取,但攝影機連接埠已關閉。請確認印表機設定中已啟用僅 LAN 模式與開發者模式。',
|
||||
no_frame: '已連線到攝影機,但未收到畫面。請重試或檢查印表機設定中攝影機是否已啟用。',
|
||||
unknown_failure: '攝影機診斷因不明原因失敗。請查看支援日誌了解詳情。',
|
||||
},
|
||||
meta: {
|
||||
protocol: '協定',
|
||||
port: '連接埠',
|
||||
profile: '設定檔',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Groups management
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -26,7 +26,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-CBOvp83T.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-C8m37mQ7.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-KYwGxnG9.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user