mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
Security hardening (maziggy/bambuddy-security #34)
This commit is contained in:
@@ -13,6 +13,7 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import (
|
||||
RequestPrinterScope,
|
||||
RequirePermissionIfAuthEnabled,
|
||||
ScopedCaller,
|
||||
caller_is_api_key,
|
||||
@@ -23,6 +24,7 @@ from backend.app.core.auth import (
|
||||
from backend.app.core.config import APP_VERSION, settings as app_settings
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.core.printer_scope import PrinterScope
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
|
||||
@@ -1736,10 +1738,18 @@ async def get_virtual_printer_models(
|
||||
}
|
||||
|
||||
|
||||
def _legacy_vp_status(status: dict, printer_scope: PrinterScope) -> dict:
|
||||
"""The first virtual printer's status, without what names its target for a limited caller."""
|
||||
if printer_scope.is_unrestricted:
|
||||
return status
|
||||
return {key: value for key, value in status.items() if key not in ("name", "serial", "target_printer_ip", "proxy")}
|
||||
|
||||
|
||||
@router.get("/virtual-printer")
|
||||
async def get_virtual_printer_settings(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""Get virtual printer settings and status."""
|
||||
from backend.app.services.virtual_printer import (
|
||||
@@ -1765,11 +1775,14 @@ async def get_virtual_printer_settings(
|
||||
# `print_queue`) come out as `archive` / `queue` for the frontend.
|
||||
"mode": normalize_vp_mode(mode) or VP_MODE_ARCHIVE,
|
||||
"model": model or DEFAULT_VIRTUAL_PRINTER_MODEL,
|
||||
"target_printer_id": int(target_printer_id) if target_printer_id else None,
|
||||
# A target the caller can't see is not named.
|
||||
"target_printer_id": int(target_printer_id)
|
||||
if target_printer_id and printer_scope.allows(int(target_printer_id))
|
||||
else None,
|
||||
"remote_interface_ip": remote_interface_ip or "",
|
||||
"tailscale_disabled": tailscale_disabled_raw == "true" if tailscale_disabled_raw else True,
|
||||
"archive_name_source": archive_name_source if archive_name_source in ("metadata", "filename") else "metadata",
|
||||
"status": virtual_printer_manager.get_status(),
|
||||
"status": _legacy_vp_status(virtual_printer_manager.get_status(), printer_scope),
|
||||
}
|
||||
|
||||
|
||||
@@ -1785,6 +1798,7 @@ async def update_virtual_printer_settings(
|
||||
archive_name_source: str = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""Update virtual printer settings and restart services if needed.
|
||||
|
||||
@@ -1821,6 +1835,18 @@ async def update_virtual_printer_settings(
|
||||
new_mode = mode if mode is not None else current_mode
|
||||
new_model = model if model is not None else current_model
|
||||
new_target_id = target_printer_id if target_printer_id is not None else current_target_id
|
||||
# One bridging to a printer the caller can't use is not theirs to change,
|
||||
# and they can't aim it at such a printer.
|
||||
from backend.app.models.virtual_printer import VirtualPrinter as FirstVP
|
||||
|
||||
# The first virtual printer is the one these settings reach.
|
||||
first_vp = (await db.execute(select(FirstVP).order_by(FirstVP.position, FirstVP.id).limit(1))).scalar_one_or_none()
|
||||
if not printer_scope.allows(current_target_id) or (
|
||||
first_vp is not None and not printer_scope.allows(first_vp.target_printer_id)
|
||||
):
|
||||
return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
|
||||
if target_printer_id is not None and not printer_scope.allows(target_printer_id):
|
||||
return JSONResponse(status_code=400, content={"detail": f"Printer with ID {target_printer_id} not found"})
|
||||
new_remote_iface = remote_interface_ip if remote_interface_ip is not None else current_remote_iface
|
||||
new_ts_disabled = tailscale_disabled if tailscale_disabled is not None else current_ts_disabled
|
||||
|
||||
@@ -1918,10 +1944,6 @@ async def update_virtual_printer_settings(
|
||||
|
||||
# Propagate tailscale_disabled to the first VirtualPrinter row so sync_from_db() picks it up
|
||||
if tailscale_disabled is not None:
|
||||
from backend.app.models.virtual_printer import VirtualPrinter as VPModel
|
||||
|
||||
vp_result = await db.execute(select(VPModel).order_by(VPModel.position).limit(1))
|
||||
first_vp = vp_result.scalar_one_or_none()
|
||||
if first_vp is not None:
|
||||
first_vp.tailscale_disabled = new_ts_disabled
|
||||
|
||||
@@ -1952,7 +1974,7 @@ async def update_virtual_printer_settings(
|
||||
content={"detail": "Failed to configure virtual printer. Check server logs for details."},
|
||||
)
|
||||
|
||||
return await get_virtual_printer_settings(db)
|
||||
return await get_virtual_printer_settings(db, printer_scope=printer_scope)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -177,7 +177,7 @@ async def get_logs(
|
||||
limit: int = Query(200, ge=1, le=1000, description="Maximum number of entries to return"),
|
||||
level: str | None = Query(None, description="Filter by log level (DEBUG, INFO, WARNING, ERROR)"),
|
||||
search: str | None = Query(None, description="Search in message or logger name"),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""Get recent application log entries with optional filtering."""
|
||||
entries, total_lines = read_log_entries(limit=limit, level_filter=level, search=search)
|
||||
@@ -1492,7 +1492,7 @@ async def _get_recent_sanitized_logs(max_lines: int = 200) -> str:
|
||||
|
||||
@router.get("/bundle")
|
||||
async def generate_support_bundle(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""Generate a support bundle ZIP file for issue reporting."""
|
||||
# Check if debug logging is enabled and collect sensitive values for redaction
|
||||
|
||||
@@ -13,11 +13,12 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.auth import RequestPrinterScope, RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.config import APP_VERSION, settings
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.local_config import read_local_toml, read_ntp_gate
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.core.printer_scope import PrinterScope
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.filament import Filament
|
||||
from backend.app.models.print_log import PrintLogEntry
|
||||
@@ -403,12 +404,17 @@ async def _get_storage_usage_cached(refresh: bool, max_age_seconds: int) -> dict
|
||||
async def get_system_info(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""Get comprehensive system information."""
|
||||
|
||||
# Database stats
|
||||
archive_count = await db.scalar(select(func.count(PrintArchive.id)))
|
||||
printer_count = await db.scalar(select(func.count(Printer.id)))
|
||||
# Printers are counted and listed as the caller's printer list shows them.
|
||||
printer_count_query = select(func.count(Printer.id))
|
||||
if (printer_clause := printer_scope.where_strict(Printer.id)) is not None:
|
||||
printer_count_query = printer_count_query.where(printer_clause)
|
||||
printer_count = await db.scalar(printer_count_query)
|
||||
filament_count = await db.scalar(select(func.count(Filament.id)))
|
||||
project_count = await db.scalar(select(func.count(Project.id)))
|
||||
smart_plug_count = await db.scalar(select(func.count(SmartPlug.id)))
|
||||
@@ -441,6 +447,8 @@ async def get_system_info(
|
||||
# Connected printers
|
||||
connected_printers = []
|
||||
for printer_id, client in printer_manager._clients.items():
|
||||
if not printer_scope.allows(printer_id):
|
||||
continue
|
||||
state = client.state
|
||||
if state and state.connected:
|
||||
# Get printer name and model from database
|
||||
@@ -596,6 +604,7 @@ async def get_storage_usage(
|
||||
async def get_system_health(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""Scan the recent application log against the known-issue catalog.
|
||||
|
||||
@@ -603,7 +612,13 @@ async def get_system_health(
|
||||
Sample lines are sanitized before they leave the process.
|
||||
"""
|
||||
sensitive_strings = await collect_sensitive_strings(db)
|
||||
return await asyncio.to_thread(scan_logs, sensitive_strings=sensitive_strings)
|
||||
result = await asyncio.to_thread(scan_logs, sensitive_strings=sensitive_strings)
|
||||
# A log line can be about any printer; a caller limited to some gets the
|
||||
# findings without them.
|
||||
if not printer_scope.is_unrestricted:
|
||||
for finding in result.findings:
|
||||
finding.sample = ""
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/db-pool")
|
||||
|
||||
@@ -8,9 +8,10 @@ from pydantic import BaseModel
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.auth import RequestPrinterScope, RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.core.printer_scope import PrinterScope
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
|
||||
from backend.app.schemas.virtual_printer import VPDiagnosticResult
|
||||
@@ -82,6 +83,14 @@ def _resolve_printer_model(printer_model: str | None) -> str | None:
|
||||
return DISPLAY_NAME_TO_MODEL_CODE.get(printer_model)
|
||||
|
||||
|
||||
def _target_missing(target_printer_id: int) -> JSONResponse:
|
||||
return JSONResponse(status_code=400, content={"detail": f"Printer with ID {target_printer_id} not found"})
|
||||
|
||||
|
||||
def _vp_missing() -> JSONResponse:
|
||||
return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
|
||||
|
||||
|
||||
async def _vp_to_dict(vp, db: AsyncSession, status: dict | None = None) -> dict:
|
||||
"""Convert VirtualPrinter model to response dict.
|
||||
|
||||
@@ -129,6 +138,7 @@ async def _vp_to_dict(vp, db: AsyncSession, status: dict | None = None) -> dict:
|
||||
async def list_virtual_printers(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""List all virtual printers with status."""
|
||||
from backend.app.models.virtual_printer import VirtualPrinter
|
||||
@@ -139,6 +149,10 @@ async def list_virtual_printers(
|
||||
|
||||
printers = []
|
||||
for vp in vps:
|
||||
# One bridging to a printer the caller can't use is left out: its
|
||||
# status names that printer.
|
||||
if not printer_scope.allows(vp.target_printer_id):
|
||||
continue
|
||||
instance = virtual_printer_manager.get_instance(vp.id)
|
||||
status = instance.get_status() if instance else {"running": False, "pending_files": 0}
|
||||
printers.append(await _vp_to_dict(vp, db, status))
|
||||
@@ -154,6 +168,7 @@ async def create_virtual_printer(
|
||||
body: VirtualPrinterCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""Create a new virtual printer."""
|
||||
from backend.app.models.virtual_printer import VP_MODE_VALUES, VirtualPrinter, normalize_vp_mode
|
||||
@@ -198,10 +213,8 @@ async def create_virtual_printer(
|
||||
|
||||
result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
|
||||
target_printer = result.scalar_one_or_none()
|
||||
if not target_printer:
|
||||
return JSONResponse(
|
||||
status_code=400, content={"detail": f"Printer with ID {body.target_printer_id} not found"}
|
||||
)
|
||||
if not target_printer or not printer_scope.allows(target_printer.id):
|
||||
return _target_missing(body.target_printer_id)
|
||||
|
||||
# Validate bind_ip uniqueness (against all enabled VPs)
|
||||
if body.bind_ip:
|
||||
@@ -319,6 +332,7 @@ async def diagnose_virtual_printer(
|
||||
vp_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""Run setup diagnostics for a virtual printer.
|
||||
|
||||
@@ -331,8 +345,8 @@ async def diagnose_virtual_printer(
|
||||
|
||||
result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
|
||||
vp = result.scalar_one_or_none()
|
||||
if not vp:
|
||||
return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
|
||||
if not vp or not printer_scope.allows(vp.target_printer_id):
|
||||
return _vp_missing()
|
||||
|
||||
instance = virtual_printer_manager.get_instance(vp.id)
|
||||
return await run_vp_diagnostic(vp, instance)
|
||||
@@ -343,6 +357,7 @@ async def get_virtual_printer(
|
||||
vp_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""Get a single virtual printer with status."""
|
||||
from backend.app.models.virtual_printer import VirtualPrinter
|
||||
@@ -350,8 +365,8 @@ async def get_virtual_printer(
|
||||
|
||||
result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
|
||||
vp = result.scalar_one_or_none()
|
||||
if not vp:
|
||||
return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
|
||||
if not vp or not printer_scope.allows(vp.target_printer_id):
|
||||
return _vp_missing()
|
||||
|
||||
instance = virtual_printer_manager.get_instance(vp.id)
|
||||
status = instance.get_status() if instance else {"running": False, "pending_files": 0}
|
||||
@@ -365,6 +380,7 @@ async def update_virtual_printer(
|
||||
body: VirtualPrinterUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""Update a virtual printer."""
|
||||
from backend.app.models.virtual_printer import VirtualPrinter
|
||||
@@ -372,8 +388,9 @@ async def update_virtual_printer(
|
||||
|
||||
result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
|
||||
vp = result.scalar_one_or_none()
|
||||
if not vp:
|
||||
return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
|
||||
# One bridging to a printer the caller can't use is not theirs to change.
|
||||
if not vp or not printer_scope.allows(vp.target_printer_id):
|
||||
return _vp_missing()
|
||||
|
||||
# Redact the access code before logging — model_dump otherwise includes
|
||||
# the plaintext value at DEBUG, violating the project no-secrets-in-logs
|
||||
@@ -419,10 +436,8 @@ async def update_virtual_printer(
|
||||
|
||||
result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
|
||||
target_printer = result.scalar_one_or_none()
|
||||
if not target_printer:
|
||||
return JSONResponse(
|
||||
status_code=400, content={"detail": f"Printer with ID {body.target_printer_id} not found"}
|
||||
)
|
||||
if not target_printer or not printer_scope.allows(target_printer.id):
|
||||
return _target_missing(body.target_printer_id)
|
||||
vp.target_printer_id = body.target_printer_id
|
||||
# Auto-inherit model from target printer in proxy mode (unless user explicitly set model)
|
||||
if body.model is None and vp.mode == "proxy" and target_printer.model:
|
||||
@@ -495,7 +510,11 @@ async def update_virtual_printer(
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"detail": f"Bind IP {vp.bind_ip} is already in use by '{conflict.name}'"},
|
||||
content={
|
||||
"detail": f"Bind IP {vp.bind_ip} is already in use by '{conflict.name}'"
|
||||
if printer_scope.allows(conflict.target_printer_id)
|
||||
else f"Bind IP {vp.bind_ip} is already in use"
|
||||
},
|
||||
)
|
||||
if effective_mode == "proxy":
|
||||
if not vp.target_printer_id:
|
||||
@@ -587,6 +606,7 @@ async def delete_virtual_printer(
|
||||
vp_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""Delete a virtual printer."""
|
||||
from backend.app.models.virtual_printer import VirtualPrinter
|
||||
@@ -594,8 +614,8 @@ async def delete_virtual_printer(
|
||||
|
||||
result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
|
||||
vp = result.scalar_one_or_none()
|
||||
if not vp:
|
||||
return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
|
||||
if not vp or not printer_scope.allows(vp.target_printer_id):
|
||||
return _vp_missing()
|
||||
|
||||
vp_name = vp.name
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""System info, support logs and virtual printers keep to the caller's printers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from backend.app.services.log_health import LogFinding, ScanResult
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
from backend.app.services.virtual_printer import virtual_printer_manager
|
||||
from backend.tests.integration.test_printer_scope_1727 import _admin_token, _auth, _group, _user
|
||||
|
||||
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
||||
|
||||
PERMS = ["printers:read", "system:read", "settings:read", "settings:update"]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_vp_services():
|
||||
with (
|
||||
patch.object(virtual_printer_manager, "sync_from_db", AsyncMock()),
|
||||
patch.object(virtual_printer_manager, "configure", AsyncMock()),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
async def _setup(client: AsyncClient, printer_factory, permissions=PERMS):
|
||||
a = await printer_factory(name="Ours", serial_number="00M09A000000111")
|
||||
b = await printer_factory(name="Theirs", serial_number="00M09A000000222")
|
||||
token = await _admin_token(client)
|
||||
perms = await _group(client, token, "perms", permissions=permissions)
|
||||
team = await _group(client, token, "team", printer_ids=[a.id])
|
||||
member = _auth((await _user(client, token, "member", [perms, team]))[0])
|
||||
return a.id, b.id, _auth(token), member
|
||||
|
||||
|
||||
async def test_system_info_lists_and_counts_only_the_caller_printers(async_client: AsyncClient, printer_factory):
|
||||
a_id, b_id, admin, member = await _setup(async_client, printer_factory)
|
||||
connected = SimpleNamespace(state=SimpleNamespace(connected=True, state="IDLE"))
|
||||
with patch.dict(printer_manager._clients, {a_id: connected, b_id: connected}):
|
||||
mine = (await async_client.get("/api/v1/system/info", headers=member)).json()["printers"]
|
||||
everyone = (await async_client.get("/api/v1/system/info", headers=admin)).json()["printers"]
|
||||
|
||||
assert [p["name"] for p in mine["connected_list"]] == ["Ours"]
|
||||
assert (mine["total"], mine["connected"]) == (1, 1)
|
||||
assert (everyone["total"], everyone["connected"]) == (2, 2)
|
||||
|
||||
|
||||
async def test_health_findings_come_without_log_lines_for_a_limited_caller(async_client: AsyncClient, printer_factory):
|
||||
a_id, b_id, admin, member = await _setup(async_client, printer_factory)
|
||||
|
||||
def scan(**_):
|
||||
finding = LogFinding(
|
||||
signature_id="x",
|
||||
severity="error",
|
||||
category="bug",
|
||||
wiki_anchor="x",
|
||||
count=1,
|
||||
first_seen="t",
|
||||
last_seen="t",
|
||||
sample="a line",
|
||||
)
|
||||
return ScanResult(findings=[finding], scanned_entries=1, log_available=True, summary={"total": 1})
|
||||
|
||||
with patch("backend.app.api.routes.system.scan_logs", scan):
|
||||
mine = (await async_client.get("/api/v1/system/health", headers=member)).json()
|
||||
everyone = (await async_client.get("/api/v1/system/health", headers=admin)).json()
|
||||
|
||||
assert [(f["signature_id"], f["sample"]) for f in mine["findings"]] == [("x", "")]
|
||||
assert everyone["findings"][0]["sample"] == "a line"
|
||||
|
||||
|
||||
async def test_logs_and_the_support_bundle_need_settings_update(async_client: AsyncClient, printer_factory):
|
||||
a_id, b_id, admin, member = await _setup(
|
||||
async_client, printer_factory, permissions=["printers:read", "settings:read"]
|
||||
)
|
||||
|
||||
logs = await async_client.get("/api/v1/support/logs", headers=member)
|
||||
bundle = await async_client.get("/api/v1/support/bundle", headers=member)
|
||||
|
||||
assert (logs.status_code, bundle.status_code) == (403, 403)
|
||||
|
||||
|
||||
async def test_a_virtual_printer_bridging_to_another_printer_is_not_shown(async_client: AsyncClient, printer_factory):
|
||||
a_id, b_id, admin, member = await _setup(async_client, printer_factory)
|
||||
created = await async_client.post(
|
||||
"/api/v1/virtual-printers", headers=admin, json={"name": "proxy", "mode": "proxy", "target_printer_id": b_id}
|
||||
)
|
||||
vp_id = created.json()["id"]
|
||||
plain = (await async_client.post("/api/v1/virtual-printers", headers=admin, json={"name": "plain"})).json()["id"]
|
||||
|
||||
listed = (await async_client.get("/api/v1/virtual-printers", headers=member)).json()
|
||||
got = await async_client.get(f"/api/v1/virtual-printers/{vp_id}", headers=member)
|
||||
diagnosed = await async_client.get(f"/api/v1/virtual-printers/{vp_id}/diagnostic", headers=member)
|
||||
missing = await async_client.get("/api/v1/virtual-printers/999999", headers=member)
|
||||
|
||||
entries = listed["printers"] if isinstance(listed, dict) else listed
|
||||
assert [vp["id"] for vp in entries] == [plain]
|
||||
assert (
|
||||
(got.status_code, got.json())
|
||||
== (diagnosed.status_code, diagnosed.json())
|
||||
== (
|
||||
missing.status_code,
|
||||
missing.json(),
|
||||
)
|
||||
)
|
||||
assert missing.status_code == 404
|
||||
|
||||
|
||||
async def test_a_member_cannot_point_a_virtual_printer_at_another_printer(async_client: AsyncClient, printer_factory):
|
||||
a_id, b_id, admin, member = await _setup(async_client, printer_factory)
|
||||
theirs = (
|
||||
await async_client.post(
|
||||
"/api/v1/virtual-printers", headers=admin, json={"name": "t", "mode": "proxy", "target_printer_id": b_id}
|
||||
)
|
||||
).json()["id"]
|
||||
ours = (
|
||||
await async_client.post(
|
||||
"/api/v1/virtual-printers", headers=member, json={"name": "o", "mode": "proxy", "target_printer_id": a_id}
|
||||
)
|
||||
).json()["id"]
|
||||
|
||||
created = await async_client.post(
|
||||
"/api/v1/virtual-printers", headers=member, json={"name": "x", "mode": "proxy", "target_printer_id": b_id}
|
||||
)
|
||||
missing = await async_client.post(
|
||||
"/api/v1/virtual-printers", headers=member, json={"name": "x", "mode": "proxy", "target_printer_id": 999_999}
|
||||
)
|
||||
retargeted = await async_client.put(
|
||||
f"/api/v1/virtual-printers/{ours}", headers=member, json={"target_printer_id": b_id}
|
||||
)
|
||||
changed = await async_client.put(f"/api/v1/virtual-printers/{theirs}", headers=member, json={"name": "mine"})
|
||||
deleted = await async_client.delete(f"/api/v1/virtual-printers/{theirs}", headers=member)
|
||||
renamed = await async_client.put(f"/api/v1/virtual-printers/{ours}", headers=member, json={"name": "ours2"})
|
||||
|
||||
await async_client.put(
|
||||
f"/api/v1/virtual-printers/{theirs}", headers=admin, json={"bind_ip": "192.0.2.10", "enabled": True}
|
||||
)
|
||||
clashing = await async_client.put(
|
||||
f"/api/v1/virtual-printers/{ours}", headers=member, json={"bind_ip": "192.0.2.10", "enabled": True}
|
||||
)
|
||||
assert clashing.status_code == 400
|
||||
assert "'" not in clashing.json()["detail"]
|
||||
assert created.status_code == missing.status_code == 400
|
||||
assert created.json()["detail"] == f"Printer with ID {b_id} not found"
|
||||
assert retargeted.status_code == 400
|
||||
assert (changed.status_code, deleted.status_code) == (404, 404)
|
||||
assert renamed.status_code == 200
|
||||
kept = (await async_client.get(f"/api/v1/virtual-printers/{theirs}", headers=admin)).json()
|
||||
assert (kept["name"], kept["target_printer_id"]) == ("t", b_id)
|
||||
assert (await async_client.get(f"/api/v1/virtual-printers/{ours}", headers=admin)).json()[
|
||||
"target_printer_id"
|
||||
] == a_id
|
||||
|
||||
|
||||
async def test_the_legacy_virtual_printer_settings_keep_to_the_caller_printers(
|
||||
async_client: AsyncClient, printer_factory
|
||||
):
|
||||
a_id, b_id, admin, member = await _setup(async_client, printer_factory)
|
||||
set_up = await async_client.put(
|
||||
f"/api/v1/settings/virtual-printer?mode=proxy&target_printer_id={b_id}", headers=admin
|
||||
)
|
||||
assert set_up.status_code == 200, set_up.text
|
||||
|
||||
shown = await async_client.get("/api/v1/settings/virtual-printer", headers=member)
|
||||
changed = await async_client.put("/api/v1/settings/virtual-printer?mode=archive", headers=member)
|
||||
|
||||
assert shown.json()["target_printer_id"] is None
|
||||
assert changed.status_code == 404
|
||||
kept = (await async_client.get("/api/v1/settings/virtual-printer", headers=admin)).json()
|
||||
assert (kept["mode"], kept["target_printer_id"]) == ("proxy", b_id)
|
||||
|
||||
await async_client.put(f"/api/v1/settings/virtual-printer?target_printer_id={a_id}", headers=admin)
|
||||
aimed = await async_client.put(f"/api/v1/settings/virtual-printer?target_printer_id={b_id}", headers=member)
|
||||
assert aimed.status_code == 400
|
||||
|
||||
# The settings also reach the first virtual printer.
|
||||
await async_client.post(
|
||||
"/api/v1/virtual-printers", headers=admin, json={"name": "first", "mode": "proxy", "target_printer_id": b_id}
|
||||
)
|
||||
toggled = await async_client.put("/api/v1/settings/virtual-printer?tailscale_disabled=false", headers=member)
|
||||
assert toggled.status_code == 404
|
||||
|
||||
|
||||
async def test_the_legacy_status_leaves_out_what_names_the_target(async_client: AsyncClient, printer_factory):
|
||||
a_id, b_id, admin, member = await _setup(async_client, printer_factory)
|
||||
status = {
|
||||
"running": True,
|
||||
"name": "N",
|
||||
"serial": "S",
|
||||
"target_printer_ip": "192.0.2.1",
|
||||
"proxy": {"target_host": "192.0.2.1"},
|
||||
}
|
||||
|
||||
with patch.object(virtual_printer_manager, "get_status", return_value=status):
|
||||
mine = (await async_client.get("/api/v1/settings/virtual-printer", headers=member)).json()["status"]
|
||||
everyone = (await async_client.get("/api/v1/settings/virtual-printer", headers=admin)).json()["status"]
|
||||
|
||||
assert mine == {"running": True}
|
||||
assert everyone == status
|
||||
@@ -14,6 +14,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from backend.app.api.routes.virtual_printers import delete_virtual_printer
|
||||
from backend.app.core.printer_scope import ALL_PRINTERS
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -56,7 +57,7 @@ async def test_delete_vp_marks_orphan_pending_uploads_discarded(tmp_path, db_ses
|
||||
"backend.app.services.virtual_printer.virtual_printer_manager",
|
||||
fake_manager,
|
||||
):
|
||||
await delete_virtual_printer(vp_id=vp_id, db=db_session, _=None)
|
||||
await delete_virtual_printer(vp_id=vp_id, db=db_session, _=None, printer_scope=ALL_PRINTERS)
|
||||
|
||||
statuses = dict(
|
||||
(
|
||||
@@ -113,7 +114,7 @@ async def test_delete_vp_with_no_orphan_uploads_still_succeeds(tmp_path):
|
||||
"backend.app.services.virtual_printer.virtual_printer_manager",
|
||||
fake_manager,
|
||||
):
|
||||
await delete_virtual_printer(vp_id=vp_id, db=db, _=None)
|
||||
await delete_virtual_printer(vp_id=vp_id, db=db, _=None, printer_scope=ALL_PRINTERS)
|
||||
|
||||
fake_manager.remove_instance.assert_awaited_once_with(vp_id)
|
||||
# No directory to remove — and we didn't crash trying to.
|
||||
|
||||
@@ -71,4 +71,27 @@ describe('SystemHealthPanel', () => {
|
||||
'https://wiki.bambuddy.cool/reference/troubleshooting/#wrong-access-code',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves out the sample line when there is none', () => {
|
||||
renderPanel({
|
||||
...BASE,
|
||||
log_available: true,
|
||||
findings: [
|
||||
{
|
||||
signature_id: 'ftp-auth-rejected',
|
||||
severity: 'error',
|
||||
category: 'layer8',
|
||||
wiki_anchor: 'wrong-access-code',
|
||||
count: 4,
|
||||
first_seen: '2026-05-22 09:00:00,000',
|
||||
last_seen: '2026-05-22 10:00:00,000',
|
||||
sample: '',
|
||||
},
|
||||
],
|
||||
summary: { total: 1, layer8: 1, environment: 0, bug: 0 },
|
||||
});
|
||||
|
||||
expect(screen.getByText('Printer rejected the access code')).toBeInTheDocument();
|
||||
expect(document.querySelector('.font-mono')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,20 @@ import { render } from '../utils';
|
||||
import { SystemInfoPage } from '../../pages/SystemInfoPage';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
// Every permission unless a test takes one away.
|
||||
const permissions = vi.hoisted(() => ({ denied: [] as string[] }));
|
||||
|
||||
vi.mock('../../contexts/AuthContext', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
|
||||
return {
|
||||
...actual,
|
||||
useAuth: () => ({
|
||||
...actual.useAuth(),
|
||||
hasPermission: (permission: string) => !permissions.denied.includes(permission),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the API client
|
||||
vi.mock('../../api/client', () => ({
|
||||
api: {
|
||||
@@ -96,6 +110,19 @@ const mockSystemInfo = {
|
||||
describe('SystemInfoPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
permissions.denied = [];
|
||||
});
|
||||
|
||||
it('shows the support tools only to users who may change settings', async () => {
|
||||
(api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(mockSystemInfo);
|
||||
permissions.denied = ['settings:update'];
|
||||
|
||||
render(<SystemInfoPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('X1C-01')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("What's in the support bundle?")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders loading state initially', async () => {
|
||||
|
||||
@@ -51,9 +51,11 @@ function FindingCard({ finding }: { finding: LogFinding }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs font-mono text-bambu-gray/70 bg-bambu-dark-secondary rounded px-3 py-2 break-all">
|
||||
{finding.sample}
|
||||
</div>
|
||||
{finding.sample && (
|
||||
<div className="text-xs font-mono text-bambu-gray/70 bg-bambu-dark-secondary rounded px-3 py-2 break-all">
|
||||
{finding.sample}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<span className="text-xs text-bambu-gray">
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
HeartPulse,
|
||||
} from 'lucide-react';
|
||||
import { api, supportApi, type Printer as PrinterModel } from '../api/client';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Card } from '../components/Card';
|
||||
import { LogViewer } from '../components/LogViewer';
|
||||
import { ConnectionDiagnosticModal } from '../components/ConnectionDiagnostic';
|
||||
@@ -98,6 +99,9 @@ function Section({
|
||||
|
||||
export function SystemInfoPage() {
|
||||
const { t } = useTranslation();
|
||||
const { hasPermission } = useAuth();
|
||||
// The logs and the bundle need settings:update, as debug logging does.
|
||||
const canUseSupportTools = hasPermission('settings:update');
|
||||
const queryClient = useQueryClient();
|
||||
const [bundleError, setBundleError] = useState<string | null>(null);
|
||||
const [bundleDownloading, setBundleDownloading] = useState(false);
|
||||
@@ -244,160 +248,162 @@ export function SystemInfoPage() {
|
||||
</Section>
|
||||
|
||||
{/* Support & Troubleshooting */}
|
||||
<Section title={t('support.title', 'Support & Troubleshooting')} icon={Headphones}>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-bambu-gray">
|
||||
{t('support.description', 'Enable debug logging to capture detailed information, then download a support bundle to share when reporting issues.')}
|
||||
</p>
|
||||
|
||||
{/* Debug Logging Toggle */}
|
||||
<div className="flex items-center justify-between p-4 bg-bambu-dark rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${debugLoggingState?.enabled ? 'bg-amber-500/20 text-amber-500' : 'bg-bambu-dark-tertiary text-bambu-gray'}`}>
|
||||
<Bug className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-white">{t('support.debugLogging', 'Debug Logging')}</p>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
{debugLoggingState?.enabled
|
||||
? t('support.debugLoggingEnabled', 'Capturing detailed logs')
|
||||
: t('support.debugLoggingDisabled', 'Normal logging level')}
|
||||
{debugLoggingState?.enabled && debugLoggingState.duration_seconds !== null && (
|
||||
<span className="text-amber-700 dark:text-amber-400 ml-2">
|
||||
({Math.floor(debugLoggingState.duration_seconds / 60)}m {debugLoggingState.duration_seconds % 60}s)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleToggleDebugLogging}
|
||||
disabled={debugToggling}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2 ${
|
||||
debugLoggingState?.enabled
|
||||
? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-500/20 dark:text-amber-400 dark:hover:bg-amber-500/30'
|
||||
: 'bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{debugToggling && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{debugLoggingState?.enabled
|
||||
? t('support.disableDebug', 'Disable')
|
||||
: t('support.enableDebug', 'Enable')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Support Bundle Download */}
|
||||
<div className="flex items-center justify-between p-4 bg-bambu-dark rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-bambu-dark-tertiary text-bambu-green">
|
||||
<Download className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-white">{t('support.supportBundle', 'Support Bundle')}</p>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
{t('support.supportBundleDescription', 'Download system info and logs as a ZIP file')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDownloadBundle}
|
||||
disabled={bundleDownloading || !debugLoggingState?.enabled}
|
||||
className="px-4 py-2 rounded-lg font-medium bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={!debugLoggingState?.enabled ? t('support.enableDebugFirst', 'Enable debug logging first') : undefined}
|
||||
>
|
||||
{bundleDownloading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{bundleDownloading
|
||||
? t('support.bundleGenerating', 'Generating...')
|
||||
: t('common.download', 'Download')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress indicator — bundle generation now runs connection +
|
||||
virtual-printer diagnostics and the log-health scan before
|
||||
writing the ZIP (#1506 follow-up), so the wait is longer than
|
||||
a pure file-export. List what's running so it's not opaque. */}
|
||||
{bundleDownloading && (
|
||||
<div className="p-3 bg-bambu-dark-tertiary/40 rounded-lg space-y-1">
|
||||
<p className="text-sm font-medium text-white flex items-center gap-2">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-bambu-green" />
|
||||
{t('support.bundleGenerating', 'Generating...')}
|
||||
</p>
|
||||
<ul className="text-xs text-bambu-gray list-disc list-inside space-y-0.5 pl-1">
|
||||
<li>{t('support.bundleStepConnection', 'Running printer connectivity checks')}</li>
|
||||
<li>{t('support.bundleStepVirtualPrinters', 'Running virtual-printer setup checks')}</li>
|
||||
<li>{t('support.bundleStepLogScan', 'Scanning recent logs for known issues')}</li>
|
||||
<li>{t('support.bundleStepBuild', 'Building the support bundle ZIP')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{bundleError && (
|
||||
<div className="p-3 bg-red-100 border border-red-300 rounded-lg text-red-700 dark:bg-red-500/20 dark:border-red-500/30 dark:text-red-400 text-sm">
|
||||
{bundleError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
{!debugLoggingState?.enabled && (
|
||||
<div className="p-4 bg-bambu-dark-tertiary/50 rounded-lg">
|
||||
<p className="text-sm text-bambu-gray">
|
||||
<span className="text-amber-700 dark:text-amber-400 font-medium">{t('support.instructions', 'To report an issue:')}</span>
|
||||
<br />
|
||||
1. {t('support.step1', 'Enable debug logging')}
|
||||
<br />
|
||||
2. {t('support.step2', 'Reproduce the issue')}
|
||||
<br />
|
||||
3. {t('support.step3', 'Download the support bundle')}
|
||||
<br />
|
||||
4. {t('support.step4', 'Attach the ZIP file to your issue report')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Privacy Info */}
|
||||
<div className="p-4 bg-bambu-dark rounded-lg space-y-3">
|
||||
<p className="text-sm font-medium text-white">{t('support.privacyTitle', 'What\'s in the support bundle?')}</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-bambu-green font-medium mb-1">{t('support.collected', 'Collected:')}</p>
|
||||
<ul className="text-bambu-gray space-y-0.5">
|
||||
<li>• {t('support.collectItem1', 'App version and debug mode')}</li>
|
||||
<li>• {t('support.collectItem2', 'OS, architecture, Python version')}</li>
|
||||
<li>• {t('support.collectItem3', 'Database statistics (counts only)')}</li>
|
||||
<li>• {t('support.collectItem4', 'Printer models and nozzle counts')}</li>
|
||||
<li>• {t('support.collectItem5', 'Non-sensitive settings (themes, formats)')}</li>
|
||||
<li>• {t('support.collectItem6', 'Debug logs (sanitized)')}</li>
|
||||
<li>• {t('support.collectItem7', 'Printer connectivity and firmware versions')}</li>
|
||||
<li>• {t('support.collectItem8', 'Integration status (Spoolman, MQTT, HA)')}</li>
|
||||
<li>• {t('support.collectItem9', 'Network interfaces (subnets only)')}</li>
|
||||
<li>• {t('support.collectItem10', 'Python package versions')}</li>
|
||||
<li>• {t('support.collectItem11', 'Database health checks')}</li>
|
||||
<li>• {t('support.collectItem12', 'Docker environment details')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-red-700 dark:text-red-400 font-medium mb-1">{t('support.notCollected', 'NOT collected:')}</p>
|
||||
<ul className="text-bambu-gray space-y-0.5">
|
||||
<li>• {t('support.notItem1', 'Printer names and serial numbers')}</li>
|
||||
<li>• {t('support.notItem2', 'Access codes and passwords')}</li>
|
||||
<li>• {t('support.notItem3', 'Email addresses')}</li>
|
||||
<li>• {t('support.notItem4', 'API keys and tokens')}</li>
|
||||
<li>• {t('support.notItem5', 'Webhook URLs')}</li>
|
||||
<li>• {t('support.notItem6', 'Your hostname or username')}</li>
|
||||
<li>• {t('support.notItem7', 'IP addresses')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-bambu-gray/70">
|
||||
{t('support.privacyNote', 'Email addresses in logs are replaced with [EMAIL], printer names with [PRINTER], serial numbers with [SERIAL], and IP addresses with [IP].')}
|
||||
{canUseSupportTools && (
|
||||
<Section title={t('support.title', 'Support & Troubleshooting')} icon={Headphones}>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-bambu-gray">
|
||||
{t('support.description', 'Enable debug logging to capture detailed information, then download a support bundle to share when reporting issues.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Log Viewer */}
|
||||
<LogViewer />
|
||||
</div>
|
||||
</Section>
|
||||
{/* Debug Logging Toggle */}
|
||||
<div className="flex items-center justify-between p-4 bg-bambu-dark rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${debugLoggingState?.enabled ? 'bg-amber-500/20 text-amber-500' : 'bg-bambu-dark-tertiary text-bambu-gray'}`}>
|
||||
<Bug className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-white">{t('support.debugLogging', 'Debug Logging')}</p>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
{debugLoggingState?.enabled
|
||||
? t('support.debugLoggingEnabled', 'Capturing detailed logs')
|
||||
: t('support.debugLoggingDisabled', 'Normal logging level')}
|
||||
{debugLoggingState?.enabled && debugLoggingState.duration_seconds !== null && (
|
||||
<span className="text-amber-700 dark:text-amber-400 ml-2">
|
||||
({Math.floor(debugLoggingState.duration_seconds / 60)}m {debugLoggingState.duration_seconds % 60}s)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleToggleDebugLogging}
|
||||
disabled={debugToggling}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2 ${
|
||||
debugLoggingState?.enabled
|
||||
? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-500/20 dark:text-amber-400 dark:hover:bg-amber-500/30'
|
||||
: 'bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{debugToggling && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{debugLoggingState?.enabled
|
||||
? t('support.disableDebug', 'Disable')
|
||||
: t('support.enableDebug', 'Enable')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Support Bundle Download */}
|
||||
<div className="flex items-center justify-between p-4 bg-bambu-dark rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-bambu-dark-tertiary text-bambu-green">
|
||||
<Download className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-white">{t('support.supportBundle', 'Support Bundle')}</p>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
{t('support.supportBundleDescription', 'Download system info and logs as a ZIP file')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDownloadBundle}
|
||||
disabled={bundleDownloading || !debugLoggingState?.enabled}
|
||||
className="px-4 py-2 rounded-lg font-medium bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={!debugLoggingState?.enabled ? t('support.enableDebugFirst', 'Enable debug logging first') : undefined}
|
||||
>
|
||||
{bundleDownloading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{bundleDownloading
|
||||
? t('support.bundleGenerating', 'Generating...')
|
||||
: t('common.download', 'Download')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress indicator — bundle generation now runs connection +
|
||||
virtual-printer diagnostics and the log-health scan before
|
||||
writing the ZIP (#1506 follow-up), so the wait is longer than
|
||||
a pure file-export. List what's running so it's not opaque. */}
|
||||
{bundleDownloading && (
|
||||
<div className="p-3 bg-bambu-dark-tertiary/40 rounded-lg space-y-1">
|
||||
<p className="text-sm font-medium text-white flex items-center gap-2">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-bambu-green" />
|
||||
{t('support.bundleGenerating', 'Generating...')}
|
||||
</p>
|
||||
<ul className="text-xs text-bambu-gray list-disc list-inside space-y-0.5 pl-1">
|
||||
<li>{t('support.bundleStepConnection', 'Running printer connectivity checks')}</li>
|
||||
<li>{t('support.bundleStepVirtualPrinters', 'Running virtual-printer setup checks')}</li>
|
||||
<li>{t('support.bundleStepLogScan', 'Scanning recent logs for known issues')}</li>
|
||||
<li>{t('support.bundleStepBuild', 'Building the support bundle ZIP')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{bundleError && (
|
||||
<div className="p-3 bg-red-100 border border-red-300 rounded-lg text-red-700 dark:bg-red-500/20 dark:border-red-500/30 dark:text-red-400 text-sm">
|
||||
{bundleError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
{!debugLoggingState?.enabled && (
|
||||
<div className="p-4 bg-bambu-dark-tertiary/50 rounded-lg">
|
||||
<p className="text-sm text-bambu-gray">
|
||||
<span className="text-amber-700 dark:text-amber-400 font-medium">{t('support.instructions', 'To report an issue:')}</span>
|
||||
<br />
|
||||
1. {t('support.step1', 'Enable debug logging')}
|
||||
<br />
|
||||
2. {t('support.step2', 'Reproduce the issue')}
|
||||
<br />
|
||||
3. {t('support.step3', 'Download the support bundle')}
|
||||
<br />
|
||||
4. {t('support.step4', 'Attach the ZIP file to your issue report')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Privacy Info */}
|
||||
<div className="p-4 bg-bambu-dark rounded-lg space-y-3">
|
||||
<p className="text-sm font-medium text-white">{t('support.privacyTitle', 'What\'s in the support bundle?')}</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-bambu-green font-medium mb-1">{t('support.collected', 'Collected:')}</p>
|
||||
<ul className="text-bambu-gray space-y-0.5">
|
||||
<li>• {t('support.collectItem1', 'App version and debug mode')}</li>
|
||||
<li>• {t('support.collectItem2', 'OS, architecture, Python version')}</li>
|
||||
<li>• {t('support.collectItem3', 'Database statistics (counts only)')}</li>
|
||||
<li>• {t('support.collectItem4', 'Printer models and nozzle counts')}</li>
|
||||
<li>• {t('support.collectItem5', 'Non-sensitive settings (themes, formats)')}</li>
|
||||
<li>• {t('support.collectItem6', 'Debug logs (sanitized)')}</li>
|
||||
<li>• {t('support.collectItem7', 'Printer connectivity and firmware versions')}</li>
|
||||
<li>• {t('support.collectItem8', 'Integration status (Spoolman, MQTT, HA)')}</li>
|
||||
<li>• {t('support.collectItem9', 'Network interfaces (subnets only)')}</li>
|
||||
<li>• {t('support.collectItem10', 'Python package versions')}</li>
|
||||
<li>• {t('support.collectItem11', 'Database health checks')}</li>
|
||||
<li>• {t('support.collectItem12', 'Docker environment details')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-red-700 dark:text-red-400 font-medium mb-1">{t('support.notCollected', 'NOT collected:')}</p>
|
||||
<ul className="text-bambu-gray space-y-0.5">
|
||||
<li>• {t('support.notItem1', 'Printer names and serial numbers')}</li>
|
||||
<li>• {t('support.notItem2', 'Access codes and passwords')}</li>
|
||||
<li>• {t('support.notItem3', 'Email addresses')}</li>
|
||||
<li>• {t('support.notItem4', 'API keys and tokens')}</li>
|
||||
<li>• {t('support.notItem5', 'Webhook URLs')}</li>
|
||||
<li>• {t('support.notItem6', 'Your hostname or username')}</li>
|
||||
<li>• {t('support.notItem7', 'IP addresses')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-bambu-gray/70">
|
||||
{t('support.privacyNote', 'Email addresses in logs are replaced with [EMAIL], printer names with [PRINTER], serial numbers with [SERIAL], and IP addresses with [IP].')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Log Viewer */}
|
||||
<LogViewer />
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Connection Diagnostic */}
|
||||
<Section title={t('diagnostic.sectionTitle', 'Connection Diagnostic')} icon={Stethoscope}>
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r,g as i,w as a}from"./client-C3LdD0iY.js";import{t as o}from"./useQuery-BSYE6Prb.js";import{a as s,t as c}from"./CameraWall-DrtBX8Nc.js";import{Bn as l,Wn as u,yt as d,zn as f}from"./index-DcFCRD_h.js";var p=e(n(),1),m=t(),h=5e3,g=4,_=8,v=1,y=16,b=2,x=60;function S(e,t,n,r){let i=parseInt(e??``,10);return Number.isFinite(i)?Math.min(r,Math.max(n,i)):t}function C(e,t,n,r,i,a){return e.has(t)?S(e.get(t),r,i,a):S(localStorage.getItem(n),r,i,a)}function w(e){let t=e.get(`size`)?.toLowerCase(),n=s.indexOf(t);if(n>=0)return n+1;let r=parseInt(localStorage.getItem(`camWallTileSize`)??``,10);return r>=1&&r<=4?r:2}function T(){let{t:e}=a(),t=l(),{authEnabled:n,loading:s,user:S}=d(),[T]=u(),E=T.get(`token`),D=E!=null&&E!==``,[O,k]=(0,p.useState)(()=>C(T,`maxLive`,`camWallMaxLive`,g,v,y)),[A,j]=(0,p.useState)(()=>C(T,`interval`,`camWallSnapshotSec`,_,b,x)),[M]=(0,p.useState)(()=>w(T)),[N,P]=(0,p.useState)(()=>{let e=T.get(`status`)??localStorage.getItem(`camWallStatusMode`);return(D?[`off`,`compact`]:[`off`,`compact`,`full`]).includes(e)?e:`compact`});(0,p.useEffect)(()=>{if(D)return i(E),()=>i(null)},[D,E]);let F=o({queryKey:[`camwall-printers`,E],queryFn:()=>r.getCamWallPrinters(E??void 0),enabled:D,refetchInterval:h}),I=o({queryKey:[`printers`],queryFn:()=>r.getPrinters(),enabled:!D&&!s&&(!n||S!==null),refetchInterval:h}),L=(0,p.useMemo)(()=>{let e=new Map;for(let t of F.data??[])e.set(t.id,{connected:t.connected,state:t.state,progress:t.progress,remaining_time:t.remaining_time,layer_num:t.layer_num,total_layers:t.total_layers,hms_errors:t.hms_errors});return e},[F.data]);if(!D&&s)return(0,m.jsx)(`div`,{className:`flex min-h-screen items-center justify-center bg-bambu-dark text-bambu-gray`,children:e(`common.loading`)});if(!D&&n&&!S)return(0,m.jsx)(f,{to:`/login`,replace:!0,state:{from:t}});let R=D?F:I,z=D?F.data??[]:I.data??[];return R.isError?(0,m.jsx)(`div`,{className:`flex min-h-screen items-center justify-center bg-bambu-dark p-6 text-center`,children:(0,m.jsx)(`p`,{className:`max-w-md text-sm text-red-400`,children:e(D?`printers.camWall.page.tokenRejected`:`printers.camWall.page.loadFailed`)})}):(0,m.jsx)(`div`,{className:`min-h-screen bg-bambu-dark p-4`,children:(0,m.jsx)(c,{printers:z,maxLive:O,snapshotIntervalSec:A,statusMode:N,tileSize:M,statuses:D?L:void 0,showSettings:!D,onTileClick:D?void 0:e=>window.open(`/camera/${e}`,`camera-${e}`),onChangeMaxLive:e=>{k(e),localStorage.setItem(`camWallMaxLive`,String(e))},onChangeSnapshotIntervalSec:e=>{j(e),localStorage.setItem(`camWallSnapshotSec`,String(e))},onChangeStatusMode:e=>{P(e),localStorage.setItem(`camWallStatusMode`,e)}})})}export{T as CamWallPage,T as default};
|
||||
import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r,g as i,w as a}from"./client-C3LdD0iY.js";import{t as o}from"./useQuery-BSYE6Prb.js";import{a as s,t as c}from"./CameraWall-CB0AsSR2.js";import{Bn as l,Wn as u,yt as d,zn as f}from"./index-CizsGNuz.js";var p=e(n(),1),m=t(),h=5e3,g=4,_=8,v=1,y=16,b=2,x=60;function S(e,t,n,r){let i=parseInt(e??``,10);return Number.isFinite(i)?Math.min(r,Math.max(n,i)):t}function C(e,t,n,r,i,a){return e.has(t)?S(e.get(t),r,i,a):S(localStorage.getItem(n),r,i,a)}function w(e){let t=e.get(`size`)?.toLowerCase(),n=s.indexOf(t);if(n>=0)return n+1;let r=parseInt(localStorage.getItem(`camWallTileSize`)??``,10);return r>=1&&r<=4?r:2}function T(){let{t:e}=a(),t=l(),{authEnabled:n,loading:s,user:S}=d(),[T]=u(),E=T.get(`token`),D=E!=null&&E!==``,[O,k]=(0,p.useState)(()=>C(T,`maxLive`,`camWallMaxLive`,g,v,y)),[A,j]=(0,p.useState)(()=>C(T,`interval`,`camWallSnapshotSec`,_,b,x)),[M]=(0,p.useState)(()=>w(T)),[N,P]=(0,p.useState)(()=>{let e=T.get(`status`)??localStorage.getItem(`camWallStatusMode`);return(D?[`off`,`compact`]:[`off`,`compact`,`full`]).includes(e)?e:`compact`});(0,p.useEffect)(()=>{if(D)return i(E),()=>i(null)},[D,E]);let F=o({queryKey:[`camwall-printers`,E],queryFn:()=>r.getCamWallPrinters(E??void 0),enabled:D,refetchInterval:h}),I=o({queryKey:[`printers`],queryFn:()=>r.getPrinters(),enabled:!D&&!s&&(!n||S!==null),refetchInterval:h}),L=(0,p.useMemo)(()=>{let e=new Map;for(let t of F.data??[])e.set(t.id,{connected:t.connected,state:t.state,progress:t.progress,remaining_time:t.remaining_time,layer_num:t.layer_num,total_layers:t.total_layers,hms_errors:t.hms_errors});return e},[F.data]);if(!D&&s)return(0,m.jsx)(`div`,{className:`flex min-h-screen items-center justify-center bg-bambu-dark text-bambu-gray`,children:e(`common.loading`)});if(!D&&n&&!S)return(0,m.jsx)(f,{to:`/login`,replace:!0,state:{from:t}});let R=D?F:I,z=D?F.data??[]:I.data??[];return R.isError?(0,m.jsx)(`div`,{className:`flex min-h-screen items-center justify-center bg-bambu-dark p-6 text-center`,children:(0,m.jsx)(`p`,{className:`max-w-md text-sm text-red-400`,children:e(D?`printers.camWall.page.tokenRejected`:`printers.camWall.page.loadFailed`)})}):(0,m.jsx)(`div`,{className:`min-h-screen bg-bambu-dark p-4`,children:(0,m.jsx)(c,{printers:z,maxLive:O,snapshotIntervalSec:A,statusMode:N,tileSize:M,statuses:D?L:void 0,showSettings:!D,onTileClick:D?void 0:e=>window.open(`/camera/${e}`,`camera-${e}`),onChangeMaxLive:e=>{k(e),localStorage.setItem(`camWallMaxLive`,String(e))},onChangeSnapshotIntervalSec:e=>{j(e),localStorage.setItem(`camWallSnapshotSec`,String(e))},onChangeStatusMode:e=>{P(e),localStorage.setItem(`camWallStatusMode`,e)}})})}export{T as CamWallPage,T as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,w as r}from"./client-C3LdD0iY.js";import{a as i,i as a}from"./info-jpcgNqAh.js";import{t as o}from"./eye-off-BKHC5oyQ.js";import{t as s}from"./grip-vertical-eoFY62AH.js";import{n as c,t as l}from"./Card-BvtE18YT.js";import{t as u}from"./Button-B2stjvVl.js";import{Gt as d,Sn as f}from"./index-DcFCRD_h.js";var p=e(n(),1),m=t();function h({isOpen:e,onClose:t,columns:n,defaultColumns:h,onSave:g}){let{t:_}=r(),[v,y]=(0,p.useState)(n),[b,x]=(0,p.useState)(null),S=(0,p.useRef)(null);if((0,p.useEffect)(()=>{e&&y(n.map(e=>({...e})))},[e,n]),(0,p.useEffect)(()=>{if(!e)return;let n=e=>{e.key===`Escape`&&t()};return window.addEventListener(`keydown`,n),()=>window.removeEventListener(`keydown`,n)},[e,t]),!e)return null;let C=e=>{y(t=>t.map((t,n)=>n===e?{...t,visible:!t.visible}:t))},w=(e,t)=>{t<0||t>=v.length||y(n=>{let r=[...n],[i]=r.splice(e,1);return r.splice(t,0,i),r})},T=(e,t)=>{S.current=t,x(t),e.dataTransfer.effectAllowed=`move`},E=(e,t)=>{e.preventDefault(),e.dataTransfer.dropEffect=`move`;let n=S.current;n!==null&&n!==t&&(w(n,t),S.current=t,x(t))},D=e=>{e.preventDefault()},O=()=>{S.current=null,x(null)},k=()=>{y(h.map(e=>({...e})))},A=()=>{g(v),t()},j=v.filter(e=>e.visible).length;return(0,m.jsx)(`div`,{className:`fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4`,onClick:t,children:(0,m.jsx)(l,{className:`w-full max-w-md max-h-[80vh] flex flex-col`,onClick:e=>e.stopPropagation(),children:(0,m.jsxs)(c,{className:`p-6 flex flex-col min-h-0`,children:[(0,m.jsx)(`h3`,{className:`text-lg font-semibold text-white mb-2`,children:_(`inventory.configureColumns`)}),(0,m.jsxs)(`p`,{className:`text-sm text-bambu-gray mb-4`,children:[_(`inventory.configureColumnsDesc`),(0,m.jsxs)(`span`,{className:`ml-2 text-bambu-gray/60`,children:[`(`,j,` `,_(`inventory.of`),` `,v.length,` `,_(`inventory.visible`),`)`]})]}),(0,m.jsx)(`div`,{className:`space-y-1 overflow-y-auto flex-1 min-h-0 pr-1`,children:v.map((e,t)=>(0,m.jsxs)(`div`,{className:`flex items-center gap-2 p-2 rounded-lg border transition-colors ${b===t?`border-bambu-green bg-bambu-green/10`:`border-bambu-dark-tertiary bg-bambu-dark-tertiary/50`} ${e.visible?``:`opacity-50`}`,draggable:!0,onDragStart:e=>T(e,t),onDragOver:e=>E(e,t),onDrop:D,onDragEnd:O,children:[(0,m.jsx)(`div`,{className:`cursor-grab text-bambu-gray/50 hover:text-bambu-gray`,children:(0,m.jsx)(s,{className:`w-4 h-4`})}),(0,m.jsx)(`span`,{className:`flex-1 font-medium text-sm text-white`,children:e.label}),(0,m.jsxs)(`div`,{className:`flex items-center gap-0.5`,children:[(0,m.jsx)(`button`,{onClick:()=>w(t,t-1),disabled:t===0,className:`p-1 rounded text-bambu-gray hover:bg-bambu-dark-secondary disabled:opacity-30 disabled:cursor-not-allowed`,title:_(`inventory.moveUp`),children:(0,m.jsx)(a,{className:`w-4 h-4`})}),(0,m.jsx)(`button`,{onClick:()=>w(t,t+1),disabled:t===v.length-1,className:`p-1 rounded text-bambu-gray hover:bg-bambu-dark-secondary disabled:opacity-30 disabled:cursor-not-allowed`,title:_(`inventory.moveDown`),children:(0,m.jsx)(i,{className:`w-4 h-4`})})]}),(0,m.jsx)(`button`,{onClick:()=>C(t),className:`p-1.5 rounded transition-colors ${e.visible?`text-bambu-green hover:bg-bambu-green/10`:`text-bambu-gray/50 hover:bg-bambu-dark-secondary`}`,title:e.visible?_(`inventory.hideColumn`):_(`inventory.showColumn`),children:e.visible?(0,m.jsx)(f,{className:`w-4 h-4`}):(0,m.jsx)(o,{className:`w-4 h-4`})})]},e.id))}),(0,m.jsxs)(`div`,{className:`flex items-center gap-3 mt-4 pt-4 border-t border-bambu-dark-tertiary`,children:[(0,m.jsxs)(u,{variant:`secondary`,onClick:k,className:`mr-auto`,children:[(0,m.jsx)(d,{className:`w-4 h-4`}),_(`inventory.reset`)]}),(0,m.jsx)(u,{variant:`secondary`,onClick:t,children:_(`inventory.cancel`)}),(0,m.jsx)(u,{onClick:A,children:_(`inventory.applyChanges`)})]})]})})})}export{h as t};
|
||||
import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,w as r}from"./client-C3LdD0iY.js";import{a as i,i as a}from"./info-jpcgNqAh.js";import{t as o}from"./eye-off-BKHC5oyQ.js";import{t as s}from"./grip-vertical-eoFY62AH.js";import{n as c,t as l}from"./Card-BvtE18YT.js";import{t as u}from"./Button-B2stjvVl.js";import{Gt as d,Sn as f}from"./index-CizsGNuz.js";var p=e(n(),1),m=t();function h({isOpen:e,onClose:t,columns:n,defaultColumns:h,onSave:g}){let{t:_}=r(),[v,y]=(0,p.useState)(n),[b,x]=(0,p.useState)(null),S=(0,p.useRef)(null);if((0,p.useEffect)(()=>{e&&y(n.map(e=>({...e})))},[e,n]),(0,p.useEffect)(()=>{if(!e)return;let n=e=>{e.key===`Escape`&&t()};return window.addEventListener(`keydown`,n),()=>window.removeEventListener(`keydown`,n)},[e,t]),!e)return null;let C=e=>{y(t=>t.map((t,n)=>n===e?{...t,visible:!t.visible}:t))},w=(e,t)=>{t<0||t>=v.length||y(n=>{let r=[...n],[i]=r.splice(e,1);return r.splice(t,0,i),r})},T=(e,t)=>{S.current=t,x(t),e.dataTransfer.effectAllowed=`move`},E=(e,t)=>{e.preventDefault(),e.dataTransfer.dropEffect=`move`;let n=S.current;n!==null&&n!==t&&(w(n,t),S.current=t,x(t))},D=e=>{e.preventDefault()},O=()=>{S.current=null,x(null)},k=()=>{y(h.map(e=>({...e})))},A=()=>{g(v),t()},j=v.filter(e=>e.visible).length;return(0,m.jsx)(`div`,{className:`fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4`,onClick:t,children:(0,m.jsx)(l,{className:`w-full max-w-md max-h-[80vh] flex flex-col`,onClick:e=>e.stopPropagation(),children:(0,m.jsxs)(c,{className:`p-6 flex flex-col min-h-0`,children:[(0,m.jsx)(`h3`,{className:`text-lg font-semibold text-white mb-2`,children:_(`inventory.configureColumns`)}),(0,m.jsxs)(`p`,{className:`text-sm text-bambu-gray mb-4`,children:[_(`inventory.configureColumnsDesc`),(0,m.jsxs)(`span`,{className:`ml-2 text-bambu-gray/60`,children:[`(`,j,` `,_(`inventory.of`),` `,v.length,` `,_(`inventory.visible`),`)`]})]}),(0,m.jsx)(`div`,{className:`space-y-1 overflow-y-auto flex-1 min-h-0 pr-1`,children:v.map((e,t)=>(0,m.jsxs)(`div`,{className:`flex items-center gap-2 p-2 rounded-lg border transition-colors ${b===t?`border-bambu-green bg-bambu-green/10`:`border-bambu-dark-tertiary bg-bambu-dark-tertiary/50`} ${e.visible?``:`opacity-50`}`,draggable:!0,onDragStart:e=>T(e,t),onDragOver:e=>E(e,t),onDrop:D,onDragEnd:O,children:[(0,m.jsx)(`div`,{className:`cursor-grab text-bambu-gray/50 hover:text-bambu-gray`,children:(0,m.jsx)(s,{className:`w-4 h-4`})}),(0,m.jsx)(`span`,{className:`flex-1 font-medium text-sm text-white`,children:e.label}),(0,m.jsxs)(`div`,{className:`flex items-center gap-0.5`,children:[(0,m.jsx)(`button`,{onClick:()=>w(t,t-1),disabled:t===0,className:`p-1 rounded text-bambu-gray hover:bg-bambu-dark-secondary disabled:opacity-30 disabled:cursor-not-allowed`,title:_(`inventory.moveUp`),children:(0,m.jsx)(a,{className:`w-4 h-4`})}),(0,m.jsx)(`button`,{onClick:()=>w(t,t+1),disabled:t===v.length-1,className:`p-1 rounded text-bambu-gray hover:bg-bambu-dark-secondary disabled:opacity-30 disabled:cursor-not-allowed`,title:_(`inventory.moveDown`),children:(0,m.jsx)(i,{className:`w-4 h-4`})})]}),(0,m.jsx)(`button`,{onClick:()=>C(t),className:`p-1.5 rounded transition-colors ${e.visible?`text-bambu-green hover:bg-bambu-green/10`:`text-bambu-gray/50 hover:bg-bambu-dark-secondary`}`,title:e.visible?_(`inventory.hideColumn`):_(`inventory.showColumn`),children:e.visible?(0,m.jsx)(f,{className:`w-4 h-4`}):(0,m.jsx)(o,{className:`w-4 h-4`})})]},e.id))}),(0,m.jsxs)(`div`,{className:`flex items-center gap-3 mt-4 pt-4 border-t border-bambu-dark-tertiary`,children:[(0,m.jsxs)(u,{variant:`secondary`,onClick:k,className:`mr-auto`,children:[(0,m.jsx)(d,{className:`w-4 h-4`}),_(`inventory.reset`)]}),(0,m.jsx)(u,{variant:`secondary`,onClick:t,children:_(`inventory.cancel`)}),(0,m.jsx)(u,{onClick:A,children:_(`inventory.applyChanges`)})]})]})})})}export{h as t};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r,t as i,w as a}from"./client-C3LdD0iY.js";import{t as o}from"./link-2-BrJ-i2LF.js";import{t as s}from"./loader-circle-CbaPxZRP.js";import{t as c}from"./triangle-alert-BUlaDyuA.js";import{Wn as l,yt as u}from"./index-DcFCRD_h.js";var d=e(n(),1),f=t();function p(e,t){let n=new URL(e);for(let[e,r]of Object.entries(t))r&&n.searchParams.set(e,r);return n.toString()}function m(){let{t:e}=a(),{authEnabled:t,loading:n}=u(),[m]=l(),[h,g]=(0,d.useState)({kind:`loading`}),_=(0,d.useRef)(!1),v=m.get(`client_id`)??``,y=m.get(`redirect_uri`)??``,b=m.get(`state`)??``,x=m.get(`code_challenge`)??``,S=m.get(`code_challenge_method`)??``,C=async()=>{g({kind:`redirecting`});try{let e=await r.connectAuthorize({client_id:v,redirect_uri:y,code_challenge:x,code_challenge_method:`S256`});window.location.replace(p(e.redirect_uri,{code:e.code,state:b}))}catch(t){g({kind:`error`,message:t instanceof Error?t.message:e(`connectedApps.authorize.failed`)})}};return(0,d.useEffect)(()=>{if(!(n||_.current)){if(_.current=!0,!t){g({kind:`error`,message:e(`connectedApps.authorize.authDisabled`)});return}if(!v||!y||!x||S!==`S256`){g({kind:`error`,message:e(`connectedApps.authorize.invalidRequest`)});return}r.getConnectAuthorizeInfo(v,y).then(e=>{e.already_granted?C():g({kind:`consent`,info:e})}).catch(t=>{g({kind:`error`,message:t instanceof i&&t.status===409?e(`connectedApps.authorize.authDisabled`):e(`connectedApps.authorize.invalidRequest`)})})}},[n,t]),(0,f.jsx)(`div`,{className:`min-h-screen flex items-center justify-center bg-bambu-dark p-4`,children:(0,f.jsxs)(`div`,{className:`max-w-md w-full space-y-6 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg`,children:[(h.kind===`loading`||h.kind===`redirecting`)&&(0,f.jsxs)(`div`,{className:`flex flex-col items-center gap-3 text-bambu-gray`,role:`status`,children:[(0,f.jsx)(s,{className:`w-8 h-8 animate-spin text-bambu-green`}),(0,f.jsx)(`p`,{children:h.kind===`loading`?e(`connectedApps.authorize.checking`):e(`connectedApps.authorize.redirecting`)})]}),h.kind===`error`&&(0,f.jsxs)(`div`,{className:`text-center space-y-3`,children:[(0,f.jsx)(c,{className:`w-10 h-10 mx-auto text-yellow-600 dark:text-yellow-400`}),(0,f.jsx)(`h1`,{className:`text-xl font-semibold text-white`,children:e(`connectedApps.authorize.errorTitle`)}),(0,f.jsx)(`p`,{className:`text-sm text-bambu-gray`,children:h.message})]}),h.kind===`consent`&&(0,f.jsxs)(f.Fragment,{children:[(0,f.jsxs)(`div`,{className:`text-center space-y-3`,children:[(0,f.jsx)(`div`,{className:`w-14 h-14 mx-auto rounded-full bg-bambu-green/20 flex items-center justify-center`,children:(0,f.jsx)(o,{className:`w-7 h-7 text-bambu-green`})}),(0,f.jsx)(`h1`,{className:`text-xl font-semibold text-white text-balance`,children:e(`connectedApps.authorize.title`,{app:h.info.app_name})}),(0,f.jsx)(`p`,{className:`text-sm text-bambu-gray`,children:e(`connectedApps.authorize.signedInAs`,{username:h.info.username})})]}),(0,f.jsx)(`p`,{className:`text-sm text-bambu-gray`,children:e(`connectedApps.authorize.sharedData`,{app:h.info.app_name})}),(0,f.jsxs)(`div`,{className:`flex gap-3`,children:[(0,f.jsx)(`button`,{type:`button`,onClick:()=>{window.location.replace(p(y,{error:`access_denied`,state:b}))},className:`flex-1 px-4 py-2 bg-bambu-dark-tertiary text-white rounded-md hover:bg-bambu-dark-tertiary/80 focus:outline-none focus-visible:ring-2 focus-visible:ring-bambu-green`,children:e(`connectedApps.authorize.deny`)}),(0,f.jsx)(`button`,{type:`button`,onClick:()=>void C(),className:`flex-1 px-4 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-white`,children:e(`connectedApps.authorize.allow`)})]})]})]})})}export{m as ConnectAuthorizePage};
|
||||
import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r,t as i,w as a}from"./client-C3LdD0iY.js";import{t as o}from"./link-2-BrJ-i2LF.js";import{t as s}from"./loader-circle-CbaPxZRP.js";import{t as c}from"./triangle-alert-BUlaDyuA.js";import{Wn as l,yt as u}from"./index-CizsGNuz.js";var d=e(n(),1),f=t();function p(e,t){let n=new URL(e);for(let[e,r]of Object.entries(t))r&&n.searchParams.set(e,r);return n.toString()}function m(){let{t:e}=a(),{authEnabled:t,loading:n}=u(),[m]=l(),[h,g]=(0,d.useState)({kind:`loading`}),_=(0,d.useRef)(!1),v=m.get(`client_id`)??``,y=m.get(`redirect_uri`)??``,b=m.get(`state`)??``,x=m.get(`code_challenge`)??``,S=m.get(`code_challenge_method`)??``,C=async()=>{g({kind:`redirecting`});try{let e=await r.connectAuthorize({client_id:v,redirect_uri:y,code_challenge:x,code_challenge_method:`S256`});window.location.replace(p(e.redirect_uri,{code:e.code,state:b}))}catch(t){g({kind:`error`,message:t instanceof Error?t.message:e(`connectedApps.authorize.failed`)})}};return(0,d.useEffect)(()=>{if(!(n||_.current)){if(_.current=!0,!t){g({kind:`error`,message:e(`connectedApps.authorize.authDisabled`)});return}if(!v||!y||!x||S!==`S256`){g({kind:`error`,message:e(`connectedApps.authorize.invalidRequest`)});return}r.getConnectAuthorizeInfo(v,y).then(e=>{e.already_granted?C():g({kind:`consent`,info:e})}).catch(t=>{g({kind:`error`,message:t instanceof i&&t.status===409?e(`connectedApps.authorize.authDisabled`):e(`connectedApps.authorize.invalidRequest`)})})}},[n,t]),(0,f.jsx)(`div`,{className:`min-h-screen flex items-center justify-center bg-bambu-dark p-4`,children:(0,f.jsxs)(`div`,{className:`max-w-md w-full space-y-6 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg`,children:[(h.kind===`loading`||h.kind===`redirecting`)&&(0,f.jsxs)(`div`,{className:`flex flex-col items-center gap-3 text-bambu-gray`,role:`status`,children:[(0,f.jsx)(s,{className:`w-8 h-8 animate-spin text-bambu-green`}),(0,f.jsx)(`p`,{children:h.kind===`loading`?e(`connectedApps.authorize.checking`):e(`connectedApps.authorize.redirecting`)})]}),h.kind===`error`&&(0,f.jsxs)(`div`,{className:`text-center space-y-3`,children:[(0,f.jsx)(c,{className:`w-10 h-10 mx-auto text-yellow-600 dark:text-yellow-400`}),(0,f.jsx)(`h1`,{className:`text-xl font-semibold text-white`,children:e(`connectedApps.authorize.errorTitle`)}),(0,f.jsx)(`p`,{className:`text-sm text-bambu-gray`,children:h.message})]}),h.kind===`consent`&&(0,f.jsxs)(f.Fragment,{children:[(0,f.jsxs)(`div`,{className:`text-center space-y-3`,children:[(0,f.jsx)(`div`,{className:`w-14 h-14 mx-auto rounded-full bg-bambu-green/20 flex items-center justify-center`,children:(0,f.jsx)(o,{className:`w-7 h-7 text-bambu-green`})}),(0,f.jsx)(`h1`,{className:`text-xl font-semibold text-white text-balance`,children:e(`connectedApps.authorize.title`,{app:h.info.app_name})}),(0,f.jsx)(`p`,{className:`text-sm text-bambu-gray`,children:e(`connectedApps.authorize.signedInAs`,{username:h.info.username})})]}),(0,f.jsx)(`p`,{className:`text-sm text-bambu-gray`,children:e(`connectedApps.authorize.sharedData`,{app:h.info.app_name})}),(0,f.jsxs)(`div`,{className:`flex gap-3`,children:[(0,f.jsx)(`button`,{type:`button`,onClick:()=>{window.location.replace(p(y,{error:`access_denied`,state:b}))},className:`flex-1 px-4 py-2 bg-bambu-dark-tertiary text-white rounded-md hover:bg-bambu-dark-tertiary/80 focus:outline-none focus-visible:ring-2 focus-visible:ring-bambu-green`,children:e(`connectedApps.authorize.deny`)}),(0,f.jsx)(`button`,{type:`button`,onClick:()=>void C(),className:`flex-1 px-4 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-white`,children:e(`connectedApps.authorize.allow`)})]})]})]})})}export{m as ConnectAuthorizePage};
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r,w as i}from"./client-C3LdD0iY.js";import{t as a}from"./useQuery-BSYE6Prb.js";import{t as o}from"./loader-circle-CbaPxZRP.js";import{t as s}from"./triangle-alert-BUlaDyuA.js";import{Un as c,Vn as l,vt as u}from"./index-DcFCRD_h.js";var d=e(n(),1),f=t();function p(){let{t:e}=i(),{id:t}=c(),{mode:n}=u(),l=(0,d.useRef)(null),p=m(l);g(l);let{data:h,isLoading:_,error:v}=a({queryKey:[`external-link`,t],queryFn:()=>r.getExternalLink(Number(t)),enabled:!!t});return _?(0,f.jsx)(`div`,{className:`flex items-center justify-center h-full`,children:(0,f.jsx)(o,{className:`w-8 h-8 text-bambu-green animate-spin`})}):v||!h?(0,f.jsxs)(`div`,{className:`flex flex-col items-center justify-center h-full gap-4 text-bambu-gray`,children:[(0,f.jsx)(s,{className:`w-12 h-12`}),(0,f.jsx)(`p`,{children:e(`common.linkNotFound`)})]}):(0,f.jsx)(`iframe`,{ref:l,src:h.url,onLoad:p,className:`h-full w-full border-0`,style:{colorScheme:n},title:h.name,sandbox:`allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox`})}function m(e){let{resolvedMode:t,darkStyle:n,darkBackground:r,darkAccent:i,lightStyle:a,lightBackground:o,lightAccent:s}=u(),c=t===`dark`,l=c?n:a,f=c?r:o,p=c?i:s,m=(0,d.useCallback)(()=>{let n=e.current;if(!n?.contentWindow)return;let r;try{r=new URL(n.src).origin}catch{return}n.contentWindow.postMessage({type:`bambuddy:theme`,mode:t,style:l,background:f,accent:p,canNavigate:!0},r)},[e,t,l,f,p]);return(0,d.useEffect)(()=>m(),[m]),(0,d.useEffect)(()=>{let t=t=>{t.source===e.current?.contentWindow&&t.data?.type===`bambuddy:theme-request`&&m()};return window.addEventListener(`message`,t),()=>window.removeEventListener(`message`,t)},[e,m]),m}var h=512;function g(e){let t=l();(0,d.useEffect)(()=>{let n=n=>{let r=e.current;if(!r?.contentWindow||n.source!==r.contentWindow||n.data?.type!==`bambuddy:navigate`)return;let i;try{i=new URL(r.src).origin}catch{return}if(n.origin!==i)return;let a=_(n.data.path);a!==null&&t(a)};return window.addEventListener(`message`,n),()=>window.removeEventListener(`message`,n)},[e,t])}function _(e){if(typeof e!=`string`||e.length>h||!e.startsWith(`/`)||e.startsWith(`//`)||e.includes(`\\`))return null;let t;try{t=new URL(e,window.location.origin)}catch{return null}return t.origin===window.location.origin?t.pathname+t.search+t.hash:null}export{p as ExternalLinkPage};
|
||||
import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r,w as i}from"./client-C3LdD0iY.js";import{t as a}from"./useQuery-BSYE6Prb.js";import{t as o}from"./loader-circle-CbaPxZRP.js";import{t as s}from"./triangle-alert-BUlaDyuA.js";import{Un as c,Vn as l,vt as u}from"./index-CizsGNuz.js";var d=e(n(),1),f=t();function p(){let{t:e}=i(),{id:t}=c(),{mode:n}=u(),l=(0,d.useRef)(null),p=m(l);g(l);let{data:h,isLoading:_,error:v}=a({queryKey:[`external-link`,t],queryFn:()=>r.getExternalLink(Number(t)),enabled:!!t});return _?(0,f.jsx)(`div`,{className:`flex items-center justify-center h-full`,children:(0,f.jsx)(o,{className:`w-8 h-8 text-bambu-green animate-spin`})}):v||!h?(0,f.jsxs)(`div`,{className:`flex flex-col items-center justify-center h-full gap-4 text-bambu-gray`,children:[(0,f.jsx)(s,{className:`w-12 h-12`}),(0,f.jsx)(`p`,{children:e(`common.linkNotFound`)})]}):(0,f.jsx)(`iframe`,{ref:l,src:h.url,onLoad:p,className:`h-full w-full border-0`,style:{colorScheme:n},title:h.name,sandbox:`allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox`})}function m(e){let{resolvedMode:t,darkStyle:n,darkBackground:r,darkAccent:i,lightStyle:a,lightBackground:o,lightAccent:s}=u(),c=t===`dark`,l=c?n:a,f=c?r:o,p=c?i:s,m=(0,d.useCallback)(()=>{let n=e.current;if(!n?.contentWindow)return;let r;try{r=new URL(n.src).origin}catch{return}n.contentWindow.postMessage({type:`bambuddy:theme`,mode:t,style:l,background:f,accent:p,canNavigate:!0},r)},[e,t,l,f,p]);return(0,d.useEffect)(()=>m(),[m]),(0,d.useEffect)(()=>{let t=t=>{t.source===e.current?.contentWindow&&t.data?.type===`bambuddy:theme-request`&&m()};return window.addEventListener(`message`,t),()=>window.removeEventListener(`message`,t)},[e,m]),m}var h=512;function g(e){let t=l();(0,d.useEffect)(()=>{let n=n=>{let r=e.current;if(!r?.contentWindow||n.source!==r.contentWindow||n.data?.type!==`bambuddy:navigate`)return;let i;try{i=new URL(r.src).origin}catch{return}if(n.origin!==i)return;let a=_(n.data.path);a!==null&&t(a)};return window.addEventListener(`message`,n),()=>window.removeEventListener(`message`,n)},[e,t])}function _(e){if(typeof e!=`string`||e.length>h||!e.startsWith(`/`)||e.startsWith(`//`)||e.includes(`\\`))return null;let t;try{t=new URL(e,window.location.origin)}catch{return null}return t.origin===window.location.origin?t.pathname+t.search+t.hash:null}export{p as ExternalLinkPage};
|
||||
+3
-3
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r,w as i}from"./client-C3LdD0iY.js";import{t as a}from"./useQuery-BSYE6Prb.js";import{t as o}from"./arrow-left-CSvb6Y3A.js";import{t as s}from"./GcodeToolpathViewer-N4Eow1eU.js";import{Vn as c,Wn as l}from"./index-DcFCRD_h.js";var u=e(n(),1),d=t();function f(){let e=c(),[t,n]=l(),{t:f}=i(),p=t.get(`archive`),m=t.get(`library_file`),h=t.get(`plate`),g=a({queryKey:[`gcode-viewer-archive-colors`,p],queryFn:()=>r.getArchiveCapabilities(Number(p)),enabled:!!p,staleTime:5*6e4,retry:!1}),_=a({queryKey:[`gcode-viewer-library-colors`,m],queryFn:()=>r.getLibraryFilePlates(Number(m)),enabled:!!m,staleTime:5*6e4,retry:!1}),v=a({queryKey:[`gcode-viewer-archive-plates`,p],queryFn:()=>r.getArchivePlates(Number(p)),enabled:!!p,staleTime:5*6e4,retry:!1}),y=(0,u.useMemo)(()=>(p?v.data?.plates:_.data?.plates)??[],[p,v.data,_.data]),b=(0,u.useMemo)(()=>h?Number(h):y.length===0?null:Math.min(...y.map(e=>e.index)),[h,y]),x=e=>{if(e===b)return;let r=new URLSearchParams(t);r.set(`plate`,String(e)),n(r,{replace:!0})},S=(0,u.useMemo)(()=>{if(p)return g.data?.filament_colors;let e=y.find(e=>e.index===b)||y[0];if(!e?.filaments?.length)return;let t=[];for(let n of e.filaments){let e=Math.max(0,(n.slot_id??1)-1);n.color&&(t[e]=n.color)}return t.length>0?t:void 0},[p,g.data,y,b]),C=(0,u.useMemo)(()=>{let e=e=>h?`${e}?plate=${encodeURIComponent(h)}`:e;return p?e(r.getArchiveGcode(Number(p))):m?e(r.getLibraryFileGcodeUrl(Number(m))):null},[p,m,h]),w=()=>{window.history.length>1?e(-1):e(p?`/archives`:`/files`)},T=p?f(`gcodeViewer.backToArchives`,`Back to Archives`):f(`gcodeViewer.backToFiles`,`Back to File Manager`);return(0,d.jsxs)(`div`,{className:`flex flex-col min-h-[calc(100vh-64px)] lg:h-screen`,children:[(0,d.jsxs)(`div`,{className:`flex-shrink-0 px-4 py-2 border-b border-bambu-dark-tertiary flex flex-wrap items-center gap-x-4 gap-y-2`,children:[(0,d.jsxs)(`button`,{type:`button`,onClick:w,className:`inline-flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors`,children:[(0,d.jsx)(o,{className:`w-4 h-4`}),T]}),y.length>1&&(0,d.jsxs)(`div`,{className:`flex flex-wrap items-center gap-1.5`,children:[(0,d.jsx)(`span`,{className:`text-xs text-bambu-gray`,children:f(`gcodeViewer.plates`,`Plates`)}),y.map(e=>(0,d.jsx)(`button`,{type:`button`,onClick:()=>x(e.index),"aria-pressed":e.index===b,title:e.name??void 0,className:`px-2 py-0.5 rounded text-xs transition-colors ${e.index===b?`bg-bambu-green text-white`:`bg-bambu-dark-tertiary text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white`}`,children:f(`gcodeViewer.plateN`,`Plate {{n}}`,{n:e.index})},e.index))]})]}),C?(0,d.jsx)(s,{gcodeUrl:C,filamentColors:S,className:`flex-1 min-h-0`}):(0,d.jsx)(`div`,{className:`flex-1 flex items-center justify-center text-sm text-bambu-gray`,children:f(`gcodeViewer.noSource`,`No file was given to preview.`)})]})}export{f as GCodeViewerPage};
|
||||
import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r,w as i}from"./client-C3LdD0iY.js";import{t as a}from"./useQuery-BSYE6Prb.js";import{t as o}from"./arrow-left-CSvb6Y3A.js";import{t as s}from"./GcodeToolpathViewer-N4Eow1eU.js";import{Vn as c,Wn as l}from"./index-CizsGNuz.js";var u=e(n(),1),d=t();function f(){let e=c(),[t,n]=l(),{t:f}=i(),p=t.get(`archive`),m=t.get(`library_file`),h=t.get(`plate`),g=a({queryKey:[`gcode-viewer-archive-colors`,p],queryFn:()=>r.getArchiveCapabilities(Number(p)),enabled:!!p,staleTime:5*6e4,retry:!1}),_=a({queryKey:[`gcode-viewer-library-colors`,m],queryFn:()=>r.getLibraryFilePlates(Number(m)),enabled:!!m,staleTime:5*6e4,retry:!1}),v=a({queryKey:[`gcode-viewer-archive-plates`,p],queryFn:()=>r.getArchivePlates(Number(p)),enabled:!!p,staleTime:5*6e4,retry:!1}),y=(0,u.useMemo)(()=>(p?v.data?.plates:_.data?.plates)??[],[p,v.data,_.data]),b=(0,u.useMemo)(()=>h?Number(h):y.length===0?null:Math.min(...y.map(e=>e.index)),[h,y]),x=e=>{if(e===b)return;let r=new URLSearchParams(t);r.set(`plate`,String(e)),n(r,{replace:!0})},S=(0,u.useMemo)(()=>{if(p)return g.data?.filament_colors;let e=y.find(e=>e.index===b)||y[0];if(!e?.filaments?.length)return;let t=[];for(let n of e.filaments){let e=Math.max(0,(n.slot_id??1)-1);n.color&&(t[e]=n.color)}return t.length>0?t:void 0},[p,g.data,y,b]),C=(0,u.useMemo)(()=>{let e=e=>h?`${e}?plate=${encodeURIComponent(h)}`:e;return p?e(r.getArchiveGcode(Number(p))):m?e(r.getLibraryFileGcodeUrl(Number(m))):null},[p,m,h]),w=()=>{window.history.length>1?e(-1):e(p?`/archives`:`/files`)},T=p?f(`gcodeViewer.backToArchives`,`Back to Archives`):f(`gcodeViewer.backToFiles`,`Back to File Manager`);return(0,d.jsxs)(`div`,{className:`flex flex-col min-h-[calc(100vh-64px)] lg:h-screen`,children:[(0,d.jsxs)(`div`,{className:`flex-shrink-0 px-4 py-2 border-b border-bambu-dark-tertiary flex flex-wrap items-center gap-x-4 gap-y-2`,children:[(0,d.jsxs)(`button`,{type:`button`,onClick:w,className:`inline-flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors`,children:[(0,d.jsx)(o,{className:`w-4 h-4`}),T]}),y.length>1&&(0,d.jsxs)(`div`,{className:`flex flex-wrap items-center gap-1.5`,children:[(0,d.jsx)(`span`,{className:`text-xs text-bambu-gray`,children:f(`gcodeViewer.plates`,`Plates`)}),y.map(e=>(0,d.jsx)(`button`,{type:`button`,onClick:()=>x(e.index),"aria-pressed":e.index===b,title:e.name??void 0,className:`px-2 py-0.5 rounded text-xs transition-colors ${e.index===b?`bg-bambu-green text-white`:`bg-bambu-dark-tertiary text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white`}`,children:f(`gcodeViewer.plateN`,`Plate {{n}}`,{n:e.index})},e.index))]})]}),C?(0,d.jsx)(s,{gcodeUrl:C,filamentColors:S,className:`flex-1 min-h-0`}):(0,d.jsx)(`div`,{className:`flex-1 flex items-center justify-center text-sm text-bambu-gray`,children:f(`gcodeViewer.noSource`,`No file was given to preview.`)})]})}export{f as GCodeViewerPage};
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r,l as i,w as a}from"./client-C3LdD0iY.js";import{t as o}from"./loader-circle-CbaPxZRP.js";import{n as s,t as c}from"./zoom-out-CpxEPaFQ.js";import{t as l}from"./file-DDkFXH-b.js";import{Gt as u,un as d}from"./index-DcFCRD_h.js";import{n as f,r as p,t as m}from"./usePreviewFullscreen-Bd6UWAwo.js";var h=e(n(),1),g=t(),_=.4,v=8,y=1.25,b=30,x=Math.log(y)/b,S=e=>Math.min(v,Math.max(_,e)),C={x:0,y:0};function w(e){return e.deltaMode===1?e.deltaY*16:e.deltaMode===2?e.deltaY*100:e.deltaY}function T({libraryFileId:e,filename:t,fileSize:n,onClose:_}){let{t:v}=a(),T=m(),{isFullscreen:E,toggleFullscreen:D}=T,O=(0,h.useRef)(null),k=(0,h.useRef)(null),[A,j]=(0,h.useState)(null),[M,N]=(0,h.useState)(!1),[P,F]=(0,h.useState)(null),[I,L]=(0,h.useState)(1),[R,z]=(0,h.useState)(C),[B,V]=(0,h.useState)(!1),H=(0,h.useRef)(1),U=(0,h.useRef)(C),W=(0,h.useRef)(new Map),G=(0,h.useRef)(null),K=(0,h.useCallback)((e,t)=>{let n=O.current,r=k.current;if(!n||!r)return e;let i=Math.max(0,(r.offsetWidth*t-n.clientWidth)/2),a=Math.max(0,(r.offsetHeight*t-n.clientHeight)/2);return{x:Math.min(i,Math.max(-i,e.x)),y:Math.min(a,Math.max(-a,e.y))}},[]),q=(0,h.useCallback)((e,t)=>{H.current=e,U.current=t,L(e),z(t)},[]),J=(0,h.useCallback)((e,t,n)=>{let r=H.current,i=S(r*e);if(i===r)return;let a=O.current,o=U.current,s={x:o.x*(i/r),y:o.y*(i/r)};if(a){let e=a.getBoundingClientRect(),c=(t??e.left+e.width/2)-(e.left+e.width/2),l=(n??e.top+e.height/2)-(e.top+e.height/2),u=i/r;s={x:c-(c-o.x)*u,y:l-(l-o.y)*u}}q(i,K(s,i))},[K,q]),Y=(0,h.useCallback)(()=>q(1,C),[q]);(0,h.useEffect)(()=>{let t=!1,a=null;if(j(null),N(!1),F(null),q(1,C),n>52428800){F(v(`fileManager.preview.tooLarge`,{size:l(n)}));return}let o={},s=i();return s&&(o.Authorization=`Bearer ${s}`),(async()=>{let n=await fetch(r.getLibraryFileDownloadUrl(e),{headers:o});if(!n.ok)throw Error(`HTTP ${n.status}`);let i=await n.blob();t||(a=URL.createObjectURL(i),j(a))})().catch(e=>{console.error(`[image-preview] load failed`,e),t||F(v(`fileManager.preview.error`))}),()=>{t=!0,a&&URL.revokeObjectURL(a)}},[e,n,v,q]),(0,h.useEffect)(()=>{let e=e=>{e.ctrlKey||e.metaKey||e.altKey||(e.key===`+`||e.key===`=`?(e.preventDefault(),J(y)):e.key===`-`?(e.preventDefault(),J(1/y)):e.key===`0`&&(e.preventDefault(),Y()))};return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[Y,J]),(0,h.useEffect)(()=>{let e=O.current;if(!e)return;let t=e=>{e.preventDefault();let t=Math.max(-30,Math.min(b,w(e)));J(Math.exp(-t*x),e.clientX,e.clientY)};return e.addEventListener(`wheel`,t,{passive:!1}),()=>e.removeEventListener(`wheel`,t)},[J,A]);let X=e=>{W.current.set(e.pointerId,{x:e.clientX,y:e.clientY}),G.current=null,W.current.size===1&&H.current>1&&(e.currentTarget.setPointerCapture(e.pointerId),V(!0))},Z=e=>{W.current.delete(e.pointerId),G.current=null,W.current.size===0&&V(!1)},Q=e=>{let t=W.current,n=t.get(e.pointerId);if(!n)return;if(e.pointerType===`mouse`&&e.buttons===0){Z(e);return}if(t.set(e.pointerId,{x:e.clientX,y:e.clientY}),t.size===1){if(H.current<=1)return;let t={x:U.current.x+(e.clientX-n.x),y:U.current.y+(e.clientY-n.y)};q(H.current,K(t,H.current));return}if(t.size!==2)return;let[r,i]=Array.from(t.values()),a=Math.hypot(r.x-i.x,r.y-i.y),o=G.current;G.current=a,o!=null&&o>0&&a>0&&J(a/o,(r.x+i.x)/2,(r.y+i.y)/2)},$=I>1;return(0,g.jsx)(f,{title:t,fullscreen:T,onClose:_,icon:(0,g.jsx)(d,{className:`w-5 h-5 text-bambu-green flex-shrink-0`}),actions:A?(0,g.jsxs)(g.Fragment,{children:[(0,g.jsx)(`button`,{onClick:()=>J(1/y),className:p,"aria-label":v(`fileManager.preview.zoomOut`),children:(0,g.jsx)(c,{className:`w-4 h-4`})}),(0,g.jsx)(`button`,{onClick:()=>J(y),className:p,"aria-label":v(`fileManager.preview.zoomIn`),children:(0,g.jsx)(s,{className:`w-4 h-4`})}),(0,g.jsx)(`button`,{onClick:Y,className:p,"aria-label":v(`fileManager.preview.resetZoom`),children:(0,g.jsx)(u,{className:`w-4 h-4`})})]}):void 0,children:(0,g.jsx)(`div`,{ref:O,"data-testid":`image-preview-content`,className:`relative flex-1 min-h-0 overflow-hidden bg-bambu-dark flex items-center justify-center touch-none ${E?``:`rounded-b-lg`} ${$?B?`cursor-grabbing`:`cursor-grab`:``}`,onDoubleClick:D,onPointerDown:X,onPointerMove:Q,onPointerUp:Z,onPointerCancel:Z,onPointerLeave:Z,children:P?(0,g.jsx)(`p`,{className:`text-bambu-gray text-center p-6`,children:P}):(0,g.jsxs)(g.Fragment,{children:[A&&(0,g.jsx)(`img`,{ref:k,src:A,alt:t,draggable:!1,onLoad:()=>N(!0),onError:()=>F(v(`fileManager.preview.error`)),style:{transform:`translate(${R.x}px, ${R.y}px) scale(${I})`},className:`max-w-full max-h-full object-contain select-none`}),!M&&(0,g.jsx)(`div`,{className:`absolute inset-0 flex items-center justify-center pointer-events-none`,children:(0,g.jsx)(o,{className:`w-8 h-8 text-bambu-green animate-spin`})})]})})})}export{T as ImagePreviewModal};
|
||||
import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r,l as i,w as a}from"./client-C3LdD0iY.js";import{t as o}from"./loader-circle-CbaPxZRP.js";import{n as s,t as c}from"./zoom-out-CpxEPaFQ.js";import{t as l}from"./file-DDkFXH-b.js";import{Gt as u,un as d}from"./index-CizsGNuz.js";import{n as f,r as p,t as m}from"./usePreviewFullscreen-Bd6UWAwo.js";var h=e(n(),1),g=t(),_=.4,v=8,y=1.25,b=30,x=Math.log(y)/b,S=e=>Math.min(v,Math.max(_,e)),C={x:0,y:0};function w(e){return e.deltaMode===1?e.deltaY*16:e.deltaMode===2?e.deltaY*100:e.deltaY}function T({libraryFileId:e,filename:t,fileSize:n,onClose:_}){let{t:v}=a(),T=m(),{isFullscreen:E,toggleFullscreen:D}=T,O=(0,h.useRef)(null),k=(0,h.useRef)(null),[A,j]=(0,h.useState)(null),[M,N]=(0,h.useState)(!1),[P,F]=(0,h.useState)(null),[I,L]=(0,h.useState)(1),[R,z]=(0,h.useState)(C),[B,V]=(0,h.useState)(!1),H=(0,h.useRef)(1),U=(0,h.useRef)(C),W=(0,h.useRef)(new Map),G=(0,h.useRef)(null),K=(0,h.useCallback)((e,t)=>{let n=O.current,r=k.current;if(!n||!r)return e;let i=Math.max(0,(r.offsetWidth*t-n.clientWidth)/2),a=Math.max(0,(r.offsetHeight*t-n.clientHeight)/2);return{x:Math.min(i,Math.max(-i,e.x)),y:Math.min(a,Math.max(-a,e.y))}},[]),q=(0,h.useCallback)((e,t)=>{H.current=e,U.current=t,L(e),z(t)},[]),J=(0,h.useCallback)((e,t,n)=>{let r=H.current,i=S(r*e);if(i===r)return;let a=O.current,o=U.current,s={x:o.x*(i/r),y:o.y*(i/r)};if(a){let e=a.getBoundingClientRect(),c=(t??e.left+e.width/2)-(e.left+e.width/2),l=(n??e.top+e.height/2)-(e.top+e.height/2),u=i/r;s={x:c-(c-o.x)*u,y:l-(l-o.y)*u}}q(i,K(s,i))},[K,q]),Y=(0,h.useCallback)(()=>q(1,C),[q]);(0,h.useEffect)(()=>{let t=!1,a=null;if(j(null),N(!1),F(null),q(1,C),n>52428800){F(v(`fileManager.preview.tooLarge`,{size:l(n)}));return}let o={},s=i();return s&&(o.Authorization=`Bearer ${s}`),(async()=>{let n=await fetch(r.getLibraryFileDownloadUrl(e),{headers:o});if(!n.ok)throw Error(`HTTP ${n.status}`);let i=await n.blob();t||(a=URL.createObjectURL(i),j(a))})().catch(e=>{console.error(`[image-preview] load failed`,e),t||F(v(`fileManager.preview.error`))}),()=>{t=!0,a&&URL.revokeObjectURL(a)}},[e,n,v,q]),(0,h.useEffect)(()=>{let e=e=>{e.ctrlKey||e.metaKey||e.altKey||(e.key===`+`||e.key===`=`?(e.preventDefault(),J(y)):e.key===`-`?(e.preventDefault(),J(1/y)):e.key===`0`&&(e.preventDefault(),Y()))};return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[Y,J]),(0,h.useEffect)(()=>{let e=O.current;if(!e)return;let t=e=>{e.preventDefault();let t=Math.max(-30,Math.min(b,w(e)));J(Math.exp(-t*x),e.clientX,e.clientY)};return e.addEventListener(`wheel`,t,{passive:!1}),()=>e.removeEventListener(`wheel`,t)},[J,A]);let X=e=>{W.current.set(e.pointerId,{x:e.clientX,y:e.clientY}),G.current=null,W.current.size===1&&H.current>1&&(e.currentTarget.setPointerCapture(e.pointerId),V(!0))},Z=e=>{W.current.delete(e.pointerId),G.current=null,W.current.size===0&&V(!1)},Q=e=>{let t=W.current,n=t.get(e.pointerId);if(!n)return;if(e.pointerType===`mouse`&&e.buttons===0){Z(e);return}if(t.set(e.pointerId,{x:e.clientX,y:e.clientY}),t.size===1){if(H.current<=1)return;let t={x:U.current.x+(e.clientX-n.x),y:U.current.y+(e.clientY-n.y)};q(H.current,K(t,H.current));return}if(t.size!==2)return;let[r,i]=Array.from(t.values()),a=Math.hypot(r.x-i.x,r.y-i.y),o=G.current;G.current=a,o!=null&&o>0&&a>0&&J(a/o,(r.x+i.x)/2,(r.y+i.y)/2)},$=I>1;return(0,g.jsx)(f,{title:t,fullscreen:T,onClose:_,icon:(0,g.jsx)(d,{className:`w-5 h-5 text-bambu-green flex-shrink-0`}),actions:A?(0,g.jsxs)(g.Fragment,{children:[(0,g.jsx)(`button`,{onClick:()=>J(1/y),className:p,"aria-label":v(`fileManager.preview.zoomOut`),children:(0,g.jsx)(c,{className:`w-4 h-4`})}),(0,g.jsx)(`button`,{onClick:()=>J(y),className:p,"aria-label":v(`fileManager.preview.zoomIn`),children:(0,g.jsx)(s,{className:`w-4 h-4`})}),(0,g.jsx)(`button`,{onClick:Y,className:p,"aria-label":v(`fileManager.preview.resetZoom`),children:(0,g.jsx)(u,{className:`w-4 h-4`})})]}):void 0,children:(0,g.jsx)(`div`,{ref:O,"data-testid":`image-preview-content`,className:`relative flex-1 min-h-0 overflow-hidden bg-bambu-dark flex items-center justify-center touch-none ${E?``:`rounded-b-lg`} ${$?B?`cursor-grabbing`:`cursor-grab`:``}`,onDoubleClick:D,onPointerDown:X,onPointerMove:Q,onPointerUp:Z,onPointerCancel:Z,onPointerLeave:Z,children:P?(0,g.jsx)(`p`,{className:`text-bambu-gray text-center p-6`,children:P}):(0,g.jsxs)(g.Fragment,{children:[A&&(0,g.jsx)(`img`,{ref:k,src:A,alt:t,draggable:!1,onLoad:()=>N(!0),onError:()=>F(v(`fileManager.preview.error`)),style:{transform:`translate(${R.x}px, ${R.y}px) scale(${I})`},className:`max-w-full max-h-full object-contain select-none`}),!M&&(0,g.jsx)(`div`,{className:`absolute inset-0 flex items-center justify-center pointer-events-none`,children:(0,g.jsx)(o,{className:`w-8 h-8 text-bambu-green animate-spin`})})]})})})}export{T as ImagePreviewModal};
|
||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{ft as e}from"./index-DcFCRD_h.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};
|
||||
import{ft as e}from"./index-CizsGNuz.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+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-DcFCRD_h.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CizsGNuz.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/client-C3LdD0iY.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/QueryClientProvider-DzWmuIjj.js">
|
||||
|
||||
Reference in New Issue
Block a user