Procházet zdrojové kódy

chore: remove dead code and fail CI on unused frontend files

vulture and knip found code nothing reaches; each hit was checked for
use by name (getattr dispatch, string references, lazy imports) and
only the dead ones were removed, about 2,700 lines:

- backend: the unused auth factories (RequireAdmin, RequirePermission,
  require_permission, require_role), the unused backend i18n package,
  printer fan/axis/motor/timelapse/liveview helpers no route uses, the
  amqtt-based virtual printer MQTT server and the FTP TLS proxy that
  nothing starts, the emailed-password reset functions (reset sends a
  link), the unreachable "password_reset_link" template branch,
  force_immediate (every notification has been sent immediately and
  also queued for the digest for a long time) and an unused parameter
- frontend: UsersPage, WifiSignal, App.css, and the unused
  @floating-ui/dom and autoprefixer packages

knip now runs in CI (frontend lint job), in test_frontend.sh and as
npm run check:knip, checking unused files and dependencies; knip.jsonc
says why the few exceptions are there. The basedpyright baseline
shrinks by 10.
maziggy před 1 týdnem
rodič
revize
b5cd369f51
33 změnil soubory, kde provedl 784 přidání a 2710 odebrání
  1. 0 80
      .basedpyright/baseline.json
  2. 4 0
      .github/workflows/ci.yml
  3. 13 3
      CONTRIBUTING.md
  4. 2 2
      backend/app/api/routes/settings.py
  5. 1 101
      backend/app/core/auth.py
  6. 0 8
      backend/app/core/printer_scope.py
  7. 0 125
      backend/app/i18n/__init__.py
  8. 0 127
      backend/app/services/bambu_mqtt.py
  9. 0 6
      backend/app/services/camera.py
  10. 5 153
      backend/app/services/email_service.py
  11. 0 13
      backend/app/services/external_camera.py
  12. 0 5
      backend/app/services/filament_deficit.py
  13. 0 5
      backend/app/services/label_renderer.py
  14. 0 15
      backend/app/services/location_service.py
  15. 0 13
      backend/app/services/network_utils.py
  16. 9 22
      backend/app/services/notification_service.py
  17. 0 11
      backend/app/services/plate_detection.py
  18. 0 37
      backend/app/services/spoolman.py
  19. 3 139
      backend/app/services/virtual_printer/mqtt_server.py
  20. 0 669
      backend/app/services/virtual_printer/tcp_proxy.py
  21. 0 41
      backend/tests/unit/services/test_email_service.py
  22. 0 37
      backend/tests/unit/services/test_notification_service.py
  23. 1 7
      backend/tests/unit/services/test_p2s_accessory_fans.py
  24. 6 6
      backend/tests/unit/test_postgres_restore_drop_cascade.py
  25. 2 2
      backend/tests/unit/test_restore_schema_compat.py
  26. 2 2
      backend/tests/unit/test_route_auth_coverage.py
  27. 16 0
      frontend/knip.jsonc
  28. 709 158
      frontend/package-lock.json
  29. 2 2
      frontend/package.json
  30. 0 42
      frontend/src/App.css
  31. 0 64
      frontend/src/components/icons/WifiSignal.tsx
  32. 0 810
      frontend/src/pages/UsersPage.tsx
  33. 9 5
      test_frontend.sh

+ 0 - 80
.basedpyright/baseline.json

@@ -3363,14 +3363,6 @@
                     "lineCount": 1
                 }
             },
-            {
-                "code": "reportAssignmentType",
-                "range": {
-                    "startColumn": 32,
-                    "endColumn": 50,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportAssignmentType",
                 "range": {
@@ -4261,38 +4253,6 @@
             }
         ],
         "./backend/app/services/email_service.py": [
-            {
-                "code": "reportAttributeAccessIssue",
-                "range": {
-                    "startColumn": 43,
-                    "endColumn": 50,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportAttributeAccessIssue",
-                "range": {
-                    "startColumn": 45,
-                    "endColumn": 49,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportAttributeAccessIssue",
-                "range": {
-                    "startColumn": 45,
-                    "endColumn": 54,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportAttributeAccessIssue",
-                "range": {
-                    "startColumn": 85,
-                    "endColumn": 94,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportArgumentType",
                 "range": {
@@ -6809,14 +6769,6 @@
             }
         ],
         "./backend/app/services/virtual_printer/mqtt_server.py": [
-            {
-                "code": "reportMissingImports",
-                "range": {
-                    "startColumn": 17,
-                    "endColumn": 29,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportGeneralTypeIssues",
                 "range": {
@@ -6827,14 +6779,6 @@
             }
         ],
         "./backend/app/services/virtual_printer/tcp_proxy.py": [
-            {
-                "code": "reportAttributeAccessIssue",
-                "range": {
-                    "startColumn": 25,
-                    "endColumn": 33,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportArgumentType",
                 "range": {
@@ -6899,30 +6843,6 @@
                     "lineCount": 1
                 }
             },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 97,
-                    "endColumn": 108,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 93,
-                    "endColumn": 104,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 49,
-                    "endColumn": 73,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportRedeclaration",
                 "range": {

+ 4 - 0
.github/workflows/ci.yml

@@ -270,6 +270,10 @@ jobs:
         working-directory: frontend
         run: npm run lint
 
+      - name: Unused files and dependencies (knip)
+        working-directory: frontend
+        run: npm run check:knip
+
   frontend-security:
     name: Frontend Security
     runs-on: ubuntu-latest

+ 13 - 3
CONTRIBUTING.md

@@ -225,7 +225,8 @@ Then regenerate the type-check lock (command in `requirements-typecheck.txt`), i
 
 ### Frontend (TypeScript/React)
 
-We use ESLint for linting and TypeScript for type checking:
+We use ESLint for linting, TypeScript for type checking and knip for unused
+files and dependencies:
 
 ```bash
 cd frontend
@@ -235,8 +236,17 @@ npm run lint
 
 # Type check
 npm run typecheck
+
+# Unused files and dependencies
+npm run check:knip
 ```
 
+knip follows imports from the entry points (`index.html`, the Vite and Vitest
+configs, and `public/sw.js`, which the browser loads by URL). A file nothing
+imports, or a package nothing uses, fails the check: delete it, or add it to
+`frontend/knip.jsonc` with a comment saying why if something loads it in a way
+knip can't see.
+
 ### Pre-commit Hooks
 
 Pre-commit hooks run automatically on `git commit` and include Ruff linting/formatting, trailing whitespace fixes, YAML/JSON validation, and import shadowing checks. To run manually:
@@ -347,7 +357,7 @@ reprint eligibility. Archive reprint still needs `queue:create` before it can en
 The easiest way to run tests is with the provided scripts in the project root:
 
 ```bash
-./test_frontend.sh    # TypeScript check + ESLint + Vitest
+./test_frontend.sh    # TypeScript check + ESLint + knip + Vitest
 ./test_backend.sh     # Ruff lint/format + type check (if .typecheck-venv exists) + PostgreSQL upgrade check (if Docker is available) + pytest (parallel)
 ./test_docker.sh      # Full Docker build, unit tests, and integration tests
 ./test_all.sh         # All of the above (frontend → backend → docker)
@@ -415,7 +425,7 @@ npm run test:coverage  # With coverage
 Pull requests trigger automated CI checks via GitHub Actions (`.github/workflows/ci.yml`):
 
 - **Backend**: Ruff lint + format check, basedpyright type check (against the baseline), unit/integration tests, PostgreSQL upgrade check, pip-audit
-- **Frontend**: ESLint, TypeScript type check, Vitest tests, production build
+- **Frontend**: ESLint, knip (unused files and dependencies), TypeScript type check, Vitest tests, production build
 - **Docker**: Full image build, backend/frontend tests in Docker, integration health checks
 - **Security**: CodeQL analysis, dependency audits
 

+ 2 - 2
backend/app/api/routes/settings.py

@@ -1057,7 +1057,7 @@ def _read_backup_manifest(temp_path: Path) -> dict:
     return data if isinstance(data, dict) else {}
 
 
-async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
+async def _import_sqlite_to_postgres(sqlite_path: Path):
     """Import data from a SQLite database file into the current PostgreSQL database.
 
     Used for cross-database restore (SQLite backup → PostgreSQL).
@@ -1534,7 +1534,7 @@ async def restore_backup(
             else:
                 # Import SQLite backup into PostgreSQL
                 logger.info("Importing SQLite backup into PostgreSQL...")
-                await _import_sqlite_to_postgres(backup_db, app_settings.database_url)
+                await _import_sqlite_to_postgres(backup_db)
 
             # 6. Replace data directories
             # For Docker compatibility: clear contents then copy (don't delete mount points)

+ 1 - 101
backend/app/core/auth.py

@@ -441,7 +441,7 @@ def _check_apikey_permissions(
     ``resolve_apikey_owner``.
 
     By default ALL requested permissions must pass (mirrors
-    ``require_permission`` / ``require_permission_if_auth_enabled``).
+    ``require_permission_if_auth_enabled``).
     When ``require_any=True``, only one needs to pass (mirrors
     ``require_any_permission_if_auth_enabled``).
     """
@@ -1658,20 +1658,6 @@ async def require_auth_if_enabled(
         )
 
 
-def require_role(required_role: str):
-    """Dependency factory for role-based access control."""
-
-    async def role_checker(current_user: Annotated[User, Depends(get_current_user)]) -> User:
-        if current_user.role != required_role:
-            raise HTTPException(
-                status_code=status.HTTP_403_FORBIDDEN,
-                detail=f"Requires {required_role} role",
-            )
-        return current_user
-
-    return role_checker
-
-
 def require_admin_if_auth_enabled():
     """Dependency factory that requires admin role if auth is enabled.
 
@@ -2160,9 +2146,6 @@ def require_printer_permission_if_auth_enabled(*permissions: str | Permission):
 
 
 # Convenience dependencies - these are functions that return Depends objects
-def RequireAdmin():
-    """Dependency that requires admin role."""
-    return Depends(require_role("admin"))
 
 
 def RequireAdminIfAuthEnabled():
@@ -2170,84 +2153,6 @@ def RequireAdminIfAuthEnabled():
     return Depends(require_admin_if_auth_enabled())
 
 
-def require_permission(*permissions: str | Permission):
-    """Dependency factory that requires user to have ALL specified permissions.
-
-    Accepts both JWT tokens (via Authorization: Bearer header) and API keys
-    (via X-API-Key header or Authorization: Bearer bb_xxx).
-
-    Args:
-        *permissions: Permission strings or Permission enum values to require
-
-    Returns:
-        A dependency function that validates permissions
-    """
-    # Convert Permission enums to strings
-    perm_strings = [p.value if isinstance(p, Permission) else p for p in permissions]
-
-    async def permission_checker(
-        credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
-        x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
-    ) -> User | None:
-        async with async_session() as db:
-            # Check for API key first (X-API-Key header)
-            if x_api_key:
-                api_key = await _validate_api_key(db, x_api_key)
-                if api_key:
-                    await authorize_api_key(db, api_key, perm_strings)
-                    return None  # API key valid, allow access
-
-            credentials_exception = HTTPException(
-                status_code=status.HTTP_401_UNAUTHORIZED,
-                detail="Could not validate credentials",
-                headers={"WWW-Authenticate": "Bearer"},
-            )
-
-            if credentials is None:
-                raise credentials_exception
-
-            token = credentials.credentials
-            # Check if it's an API key (starts with bb_)
-            if token.startswith("bb_"):
-                api_key = await _validate_api_key(db, token)
-                if api_key:
-                    await authorize_api_key(db, api_key, perm_strings)
-                    return None  # API key valid, allow access
-                raise HTTPException(
-                    status_code=status.HTTP_401_UNAUTHORIZED,
-                    detail="Invalid API key",
-                    headers={"WWW-Authenticate": "Bearer"},
-                )
-
-            # Otherwise treat as JWT
-            try:
-                payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
-                username: str = payload.get("sub")
-                if username is None:
-                    raise credentials_exception
-                jti: str | None = payload.get("jti")
-                if not jti or await is_jti_revoked(jti, db):
-                    raise credentials_exception
-                iat: int | float | None = payload.get("iat")
-            except JWTError:
-                raise credentials_exception
-
-            user = await get_user_by_username(db, username)
-            if user is None or not user.is_active:
-                raise credentials_exception
-            if not _is_token_fresh(iat, user):
-                raise credentials_exception
-
-            if not user.has_all_permissions(*perm_strings):
-                raise HTTPException(
-                    status_code=status.HTTP_403_FORBIDDEN,
-                    detail=f"Missing required permissions: {', '.join(perm_strings)}",
-                )
-            return user
-
-    return permission_checker
-
-
 def require_permission_if_auth_enabled(*permissions: str | Permission):
     """Dependency factory that checks permissions only if auth is enabled.
 
@@ -2359,11 +2264,6 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
     return permission_checker
 
 
-def RequirePermission(*permissions: str | Permission):
-    """Convenience dependency that requires ALL specified permissions."""
-    return Depends(require_permission(*permissions))
-
-
 def RequirePermissionIfAuthEnabled(*permissions: str | Permission):
     """Convenience dependency that requires permissions if auth is enabled."""
     return Depends(require_permission_if_auth_enabled(*permissions))

+ 0 - 8
backend/app/core/printer_scope.py

@@ -189,11 +189,3 @@ def api_key_own_scope(api_key) -> PrinterScope:
     if api_key.printer_ids is None:
         return ALL_PRINTERS
     return PrinterScope(frozenset(int(pid) for pid in api_key.printer_ids))
-
-
-async def group_restricts_printers(db: AsyncSession, group_ids: Iterable[int]) -> bool:
-    ids = list(group_ids)
-    if not ids:
-        return False
-    result = await db.execute(select(Group.id).where(Group.id.in_(ids), Group.restrict_printers.is_(True)).limit(1))
-    return result.first() is not None

+ 0 - 125
backend/app/i18n/__init__.py

@@ -1,125 +0,0 @@
-"""Internationalization module for backend notifications."""
-
-from typing import Any
-
-# English translations
-EN = {
-    "notification": {
-        # Print events
-        "print_started": "Print Started",
-        "print_completed": "Print Completed",
-        "print_failed": "Print Failed",
-        "print_stopped": "Print Stopped",
-        "print_ended": "Print Ended",
-        "print_progress": "Print {progress}% Complete",
-        "estimated": "Estimated",
-        "time": "Time",
-        "filament": "Filament",
-        "reason": "Reason",
-        "unknown": "Unknown",
-        # Printer events
-        "printer_offline": "Printer Offline",
-        "printer_disconnected": "{printer} has disconnected",
-        "printer_error": "Printer Error: {error_type}",
-        # Filament
-        "filament_low": "Filament Low",
-        "slot_at_percent": "{printer}: Slot {slot} at {percent}%",
-        # Maintenance
-        "maintenance_due": "Maintenance Due",
-        "overdue": "OVERDUE",
-        "soon": "Soon",
-        # Test notification
-        "test_title": "Bambuddy Test",
-        "test_message": "This is a test notification from Bambuddy. If you see this, notifications are working correctly!",
-    }
-}
-
-# German translations
-DE = {
-    "notification": {
-        # Print events
-        "print_started": "Druck gestartet",
-        "print_completed": "Druck abgeschlossen",
-        "print_failed": "Druck fehlgeschlagen",
-        "print_stopped": "Druck gestoppt",
-        "print_ended": "Druck beendet",
-        "print_progress": "Druck {progress}% fertig",
-        "estimated": "Geschätzt",
-        "time": "Zeit",
-        "filament": "Filament",
-        "reason": "Grund",
-        "unknown": "Unbekannt",
-        # Printer events
-        "printer_offline": "Drucker offline",
-        "printer_disconnected": "{printer} wurde getrennt",
-        "printer_error": "Druckerfehler: {error_type}",
-        # Filament
-        "filament_low": "Wenig Filament",
-        "slot_at_percent": "{printer}: Slot {slot} bei {percent}%",
-        # Maintenance
-        "maintenance_due": "Wartung fällig",
-        "overdue": "ÜBERFÄLLIG",
-        "soon": "Bald",
-        # Test notification
-        "test_title": "Bambuddy Test",
-        "test_message": "Dies ist eine Testbenachrichtigung von Bambuddy. Wenn Sie dies sehen, funktionieren die Benachrichtigungen!",
-    }
-}
-
-# All available translations
-TRANSLATIONS = {
-    "en": EN,
-    "de": DE,
-}
-
-
-def get_translation(lang: str, key: str, **kwargs: Any) -> str:
-    """
-    Get a translation string by key with optional interpolation.
-
-    Args:
-        lang: Language code (e.g., 'en', 'de')
-        key: Dot-separated key path (e.g., 'notification.print_started')
-        **kwargs: Values to interpolate into the string
-
-    Returns:
-        Translated string, or the key if not found
-    """
-    # Fall back to English if language not found
-    translations = TRANSLATIONS.get(lang, TRANSLATIONS["en"])
-
-    # Navigate to the nested key
-    keys = key.split(".")
-    value = translations
-    for k in keys:
-        if isinstance(value, dict) and k in value:
-            value = value[k]
-        else:
-            # Key not found, fall back to English
-            value = TRANSLATIONS["en"]
-            for k2 in keys:
-                if isinstance(value, dict) and k2 in value:
-                    value = value[k2]
-                else:
-                    return key  # Return key if not found in fallback either
-            break
-
-    if isinstance(value, str):
-        # Interpolate values
-        try:
-            return value.format(**kwargs)
-        except KeyError:
-            return value
-
-    return key
-
-
-class Translator:
-    """Helper class for translations with a specific language."""
-
-    def __init__(self, lang: str = "en"):
-        self.lang = lang if lang in TRANSLATIONS else "en"
-
-    def t(self, key: str, **kwargs: Any) -> str:
-        """Translate a key."""
-        return get_translation(self.lang, key, **kwargs)

+ 0 - 127
backend/app/services/bambu_mqtt.py

@@ -5937,20 +5937,6 @@ class BambuMQTTClient:
         # The correct nozzle data comes from push_status response.
         return True
 
-    def _request_accessories(self):
-        """Request accessories info (nozzle type, etc.) from printer."""
-        if self._client:
-            self._sequence_id += 1
-            message = {
-                "system": {
-                    "sequence_id": str(self._sequence_id),
-                    "command": "get_accessories",
-                    "accessory_type": "none",
-                }
-            }
-            logger.debug("[%s] Requesting accessories info", self.serial_number)
-            self._client.publish(self.topic_publish, json.dumps(message), qos=1)
-
     def _prime_kprofile_request(self):
         """Send a priming K-profile request on connect.
 
@@ -7502,22 +7488,6 @@ class BambuMQTTClient:
         speed = max(0, min(255, speed))  # Clamp to 0-255
         return self.send_gcode(f"M106 P{fan} S{speed}")
 
-    def set_part_fan(self, speed: int) -> bool:
-        """Set part cooling fan speed (0-255)."""
-        return self.set_fan_speed(1, speed)
-
-    def set_aux_fan(self, speed: int) -> bool:
-        """Set auxiliary fan speed (0-255)."""
-        return self.set_fan_speed(2, speed)
-
-    def set_chamber_fan(self, speed: int) -> bool:
-        """Set chamber fan speed (0-255)."""
-        return self.set_fan_speed(3, speed)
-
-    def set_left_aux_fan(self, speed: int) -> bool:
-        """Set left auxiliary part cooling fan speed (0-255). P2S/X2D accessory."""
-        return self.set_fan_speed(10, speed)
-
     def set_airduct_mode(self, mode: str) -> bool:
         """Set air conditioning mode (cooling or heating).
 
@@ -7610,56 +7580,6 @@ class BambuMQTTClient:
         )
         return True
 
-    def home_axes(self, axes: str = "XYZ") -> bool:
-        """Run the printer's full auto-home sequence.
-
-        The ``axes`` argument is ignored: a bare ``G28`` is always sent so
-        Bambu firmware runs its safe multi-step routine (park toolhead →
-        home XY → home Z). Partial-axis variants like ``G28 Z`` skip the
-        toolhead-park step and can crash the bed into the toolhead on H2C
-        / H2D / H2S / X1 where Z-home moves the bed UP — see #1052.
-        """
-        return self.send_gcode("G28")
-
-    def move_axis(self, axis: str, distance: float, speed: int = 3000) -> bool:
-        """Move an axis by a relative distance.
-
-        Args:
-            axis: Axis to move ("X", "Y", or "Z")
-            distance: Distance to move in mm (positive or negative)
-            speed: Movement speed in mm/min
-
-        Returns:
-            True if command was sent, False otherwise
-        """
-        axis = axis.upper()
-        if axis not in ("X", "Y", "Z"):
-            logger.warning("[%s] Invalid axis: %s", self.serial_number, axis)
-            return False
-
-        # G91 = relative mode, G0 = rapid move, G90 = back to absolute
-        gcode = f"G91\nG0 {axis}{distance:.2f} F{speed}\nG90"
-        return self.send_gcode(gcode)
-
-    def disable_motors(self) -> bool:
-        """Disable all stepper motors.
-
-        Warning: This will cause the printer to lose its position.
-        A homing operation will be required before printing.
-
-        Returns:
-            True if command was sent, False otherwise
-        """
-        return self.send_gcode("M18")
-
-    def enable_motors(self) -> bool:
-        """Enable all stepper motors.
-
-        Returns:
-            True if command was sent, False otherwise
-        """
-        return self.send_gcode("M17")
-
     def ams_load_filament(self, tray_id: int, extruder_id: int | None = None) -> bool:
         """Load filament from a specific AMS tray.
 
@@ -8315,53 +8235,6 @@ class BambuMQTTClient:
         self._client.publish(self.topic_publish, command_json, qos=1)
         return True
 
-    def set_timelapse(self, enable: bool) -> bool:
-        """Enable or disable timelapse recording.
-
-        Args:
-            enable: True to enable, False to disable
-
-        Returns:
-            True if command was sent, False otherwise
-        """
-        if not self._client or not self.state.connected:
-            logger.warning("[%s] Cannot set timelapse: not connected", self.serial_number)
-            return False
-
-        command = {"pushing": {"command": "pushall", "sequence_id": "0"}}
-        # First send the timelapse setting
-        timelapse_cmd = {
-            "print": {"command": "gcode_line", "param": f"M981 S{1 if enable else 0} P20000", "sequence_id": "0"}
-        }
-        self._client.publish(self.topic_publish, json.dumps(timelapse_cmd), qos=1)
-        # Request status update
-        self._client.publish(self.topic_publish, json.dumps(command), qos=1)
-        logger.info("[%s] Set timelapse %s", self.serial_number, "enabled" if enable else "disabled")
-        return True
-
-    def set_liveview(self, enable: bool) -> bool:
-        """Enable or disable live view / camera streaming.
-
-        Args:
-            enable: True to enable, False to disable
-
-        Returns:
-            True if command was sent, False otherwise
-        """
-        if not self._client or not self.state.connected:
-            logger.warning("[%s] Cannot set liveview: not connected", self.serial_number)
-            return False
-
-        command = {
-            "xcam": {"command": "ipcam_record_set", "control": "enable" if enable else "disable", "sequence_id": "0"}
-        }
-        self._client.publish(self.topic_publish, json.dumps(command), qos=1)
-        # Request status update
-        pushall = {"pushing": {"command": "pushall", "sequence_id": "0"}}
-        self._client.publish(self.topic_publish, json.dumps(pushall), qos=1)
-        logger.info("[%s] Set liveview %s", self.serial_number, "enabled" if enable else "disabled")
-        return True
-
     def execute_hms_action(self, print_error: str, action: str, job_id: str | None = None) -> bool:
         """Dispatch the user's choice from the HMS-error modal as a printer command.
 

+ 0 - 6
backend/app/services/camera.py

@@ -406,12 +406,6 @@ def is_chamber_image_model(model: str | None) -> bool:
     return not supports_rtsp(model)
 
 
-def build_camera_url(ip_address: str, access_code: str, model: str | None) -> str:
-    """Build the RTSPS URL for the printer camera (RTSP models only)."""
-    port = get_camera_port(model)
-    return f"rtsps://bblp:{access_code}@{ip_address}:{port}/streaming/live/1"
-
-
 def _create_chamber_auth_payload(access_code: str) -> bytes:
     """Create the 80-byte authentication payload for chamber image protocol.
 

+ 5 - 153
backend/app/services/email_service.py

@@ -330,80 +330,6 @@ BamBuddy Team
     return subject, text_body, html_body
 
 
-def create_password_reset_email(username: str, password: str, login_url: str) -> tuple[str, str, str]:
-    """Create password reset email content.
-
-    Args:
-        username: Username of the user
-        password: New auto-generated password
-        login_url: URL to login page
-
-    Returns:
-        Tuple of (subject, text_body, html_body)
-    """
-    subject = "BamBuddy - Your Password Has Been Reset"
-
-    text_body = f"""Your BamBuddy password has been reset.
-
-Your login details:
-
-Username: {username}
-New Password: {password}
-
-You can login at: {login_url}
-
-For security reasons, please change your password after logging in.
-
-If you did not request this password reset, please contact your administrator immediately.
-
-Best regards,
-BamBuddy Team
-"""
-
-    html_body = f"""<!DOCTYPE html>
-<html>
-<head>
-    <meta charset="utf-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0">
-</head>
-<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
-    <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background-color: #667eea; padding: 20px; border-radius: 8px 8px 0 0;">
-        <h1 style="color: #ffffff; margin: 0; font-size: 24px; text-shadow: 0 1px 2px rgba(0,0,0,0.3);">Password Reset</h1>
-    </div>
-    <div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
-        <p style="font-size: 16px;">Your BamBuddy password has been reset.</p>
-
-        <div style="background: white; padding: 20px; border-radius: 4px; margin: 20px 0; border-left: 4px solid #667eea;">
-            <p style="margin: 0 0 10px 0;"><strong>Username:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{username}</code></p>
-            <p style="margin: 0;"><strong>New Password:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{password}</code></p>
-        </div>
-
-        <div style="text-align: center; margin: 30px 0;">
-            <a href="{login_url}" style="display: inline-block; background-color: #667eea; color: #ffffff; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;">Login Now</a>
-        </div>
-
-        <div style="background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 15px; margin: 20px 0;">
-            <p style="margin: 0; font-size: 14px; color: #856404;">
-                <strong>⚠️ Security Alert:</strong> If you did not request this password reset, please contact your administrator immediately.
-            </p>
-        </div>
-
-        <p style="font-size: 14px; color: #666; border-top: 1px solid #ddd; padding-top: 20px; margin-top: 20px;">
-            <strong>Security Note:</strong> For security reasons, please change your password after logging in.
-        </p>
-
-        <p style="font-size: 14px; color: #999; margin-top: 30px;">
-            Best regards,<br>
-            BamBuddy Team
-        </p>
-    </div>
-</body>
-</html>
-"""
-
-    return subject, text_body, html_body
-
-
 def create_password_reset_link_email(username: str, reset_url: str) -> tuple[str, str, str]:
     """Create a password-reset email that contains a secure link (not a plaintext password)."""
     subject = "BamBuddy - Password Reset Request"
@@ -455,17 +381,11 @@ BamBuddy Team
 async def create_password_reset_link_email_from_template(
     db: AsyncSession, username: str, reset_url: str
 ) -> tuple[str, str, str]:
-    """Create password-reset link email, using DB template if configured."""
-    template = await get_notification_template(db, "password_reset_link")
-    if template:
-        variables = {"username": username, "reset_url": reset_url}
-        subject = render_template(template.subject or "BamBuddy - Password Reset Request", variables)
-        text_body = render_template(template.body or "", variables)
-        html_body = render_template(template.html_body or "", variables) if template.html_body else None
-        if not html_body:
-            _, text_body, html_body = create_password_reset_link_email(username, reset_url)
-            return subject, text_body, html_body
-        return subject, text_body, html_body
+    """Create the password-reset link email.
+
+    There is no notification template for it (the seeded "password_reset" one is
+    for the old emailed-password flow), so this is always the built-in email.
+    """
     return create_password_reset_link_email(username, reset_url)
 
 
@@ -531,74 +451,6 @@ async def create_welcome_email_from_template(
         return create_welcome_email(username, password, login_url)
 
 
-async def create_password_reset_email_from_template(
-    db: AsyncSession, username: str, password: str, login_url: str, app_name: str = "BamBuddy"
-) -> tuple[str, str, str]:
-    """Create password reset email content using notification template from database.
-
-    Args:
-        db: Database session
-        username: Username of the user
-        password: New auto-generated password
-        login_url: URL to login page
-        app_name: Application name (default: BamBuddy)
-
-    Returns:
-        Tuple of (subject, text_body, html_body)
-    """
-    # Try to get template from database
-    template = await get_notification_template(db, "password_reset")
-
-    if template:
-        # Render template with variables
-        variables = {
-            "app_name": app_name,
-            "username": username,
-            "password": password,
-            "login_url": login_url,
-        }
-
-        subject = render_template(template.title_template, variables)
-        text_body = render_template(template.body_template, variables)
-
-        # Create HTML version with embedded login button
-        # Escape text_body to prevent XSS vulnerabilities and convert newlines to <br> tags
-        escaped_text_body = html.escape(text_body).replace("\n", "<br>\n")
-        html_body = f"""<!DOCTYPE html>
-<html>
-<head>
-    <meta charset="utf-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0">
-</head>
-<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
-    <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background-color: #667eea; padding: 30px; border-radius: 8px 8px 0 0;">
-        <h1 style="color: #ffffff; margin: 0; font-size: 24px; text-shadow: 0 1px 2px rgba(0,0,0,0.3);">{html.escape(subject)}</h1>
-    </div>
-    <div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
-        <div style="font-size: 16px;">{escaped_text_body}</div>
-
-        <div style="text-align: center; margin: 30px 0;">
-            <a href="{login_url}" style="display: inline-block; background-color: #667eea; color: #ffffff; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;">Login Now</a>
-        </div>
-
-        <div style="background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 15px; margin: 20px 0;">
-            <p style="margin: 0; font-size: 14px; color: #856404;">
-                <strong>⚠️ Security Alert:</strong> If you did not request this password reset, please contact your administrator immediately.
-            </p>
-        </div>
-    </div>
-</body>
-</html>
-"""
-
-        logger.info("Using custom password reset email template from database")
-        return subject, text_body, html_body
-    else:
-        # Fallback to hardcoded template
-        logger.warning("No password reset email template found in database, using default")
-        return create_password_reset_email(username, password, login_url)
-
-
 async def send_user_print_notification(
     db: AsyncSession,
     event_type: str,

+ 0 - 13
backend/app/services/external_camera.py

@@ -153,19 +153,6 @@ def _sanitize_camera_url(url: str, allowed_schemes: tuple[str, ...] = ("http", "
         return None
 
 
-def _validate_camera_url(url: str, allowed_schemes: tuple[str, ...] = ("http", "https", "rtsp")) -> bool:
-    """Validate camera URL format (legacy wrapper).
-
-    Args:
-        url: URL to validate
-        allowed_schemes: Tuple of allowed URL schemes
-
-    Returns:
-        True if URL is valid, False otherwise
-    """
-    return _sanitize_camera_url(url, allowed_schemes) is not None
-
-
 def list_usb_cameras() -> list[dict]:
     """List available USB cameras (V4L2 devices on Linux).
 

+ 0 - 5
backend/app/services/filament_deficit.py

@@ -255,11 +255,6 @@ def _material_identity_spoolman(spool: dict | None) -> str:
     return f"unmatched:{spool_id}"
 
 
-def _ams_id_from_global(global_tray_id: int) -> int:
-    """Inverse of ``_global_to_ams_key`` returning ams_id only."""
-    return _global_to_ams_key(global_tray_id)[0]
-
-
 def _extruder_side_for_ams(
     ams_id: int,
     ams_extruder_map: dict[str, int],

+ 0 - 5
backend/app/services/label_renderer.py

@@ -162,11 +162,6 @@ def _color_from_hex(hex_str: str | None, fallback: Color = HexColor(0x808080)) -
         return fallback
 
 
-def _luminance(color: Color) -> float:
-    """Perceived luminance of a ReportLab Color (0–1, WCAG-style approximation)."""
-    return 0.299 * color.red + 0.587 * color.green + 0.114 * color.blue
-
-
 def _hex_code_label(rgba: str | None) -> str:
     """Format ``data.rgba`` as a printable ``#RRGGBB`` string for the label.
 

+ 0 - 15
backend/app/services/location_service.py

@@ -213,21 +213,6 @@ async def count_internal_spools_at_location(db: AsyncSession, location_id: int)
     return int(result.scalar() or 0)
 
 
-async def count_spools_at_location_by_name(db: AsyncSession, name: str) -> int:
-    normalized = name.strip()
-    if not normalized:
-        return 0
-    result = await db.execute(
-        select(func.count())
-        .select_from(Spool)
-        .where(
-            Spool.archived_at.is_(None),
-            func.lower(func.trim(Spool.storage_location)) == normalized.lower(),
-        )
-    )
-    return int(result.scalar() or 0)
-
-
 async def enrich_spool_dicts_with_location_id(db: AsyncSession, spools: list[dict]) -> None:
     """Attach location_id to mapped Spoolman-style spool dicts in place."""
     keys = {location_name_key(s["storage_location"]) for s in spools if (s.get("storage_location") or "").strip()}

+ 0 - 13
backend/app/services/network_utils.py

@@ -382,16 +382,3 @@ def find_interface_for_ip(target_ip: str) -> dict | None:
 
     logger.warning("No interface found for target IP %s", target_ip)
     return None
-
-
-def get_other_interfaces(exclude_ip: str) -> list[dict]:
-    """Get all interfaces except the one with the given IP.
-
-    Args:
-        exclude_ip: IP address of interface to exclude
-
-    Returns:
-        List of interface dicts
-    """
-    interfaces = get_network_interfaces()
-    return [iface for iface in interfaces if iface["ip"] != exclude_ip]

+ 9 - 22
backend/app/services/notification_service.py

@@ -1624,7 +1624,6 @@ class NotificationService:
         event_type: str = "unknown",
         printer_id: int | None = None,
         printer_name: str | None = None,
-        force_immediate: bool = False,
         image_data: bytes | None = None,
         variables: dict | None = None,
     ):
@@ -2041,7 +2040,6 @@ class NotificationService:
             "print_missing_spool_assignment",
             printer_id,
             printer_name,
-            force_immediate=True,
             variables=variables,
         )
 
@@ -2074,7 +2072,6 @@ class NotificationService:
             "billing_charge_failed",
             printer_id,
             printer_name,
-            force_immediate=True,
             variables=variables,
         )
 
@@ -2190,7 +2187,6 @@ class NotificationService:
             "plate_not_empty",
             printer_id,
             printer_name,
-            force_immediate=True,
             image_data=image_data,
             variables=variables,
         )
@@ -2297,7 +2293,7 @@ class NotificationService:
         threshold: float,
         db: AsyncSession,
     ):
-        """Handle AMS high humidity alarm event. Always sends immediately (bypasses digest)."""
+        """Handle AMS high humidity alarm event. Sent immediately, and also in the daily digest if enabled."""
         providers = await self._get_providers_for_event(db, "on_ams_humidity_high", printer_id)
         if not providers:
             return
@@ -2310,7 +2306,6 @@ class NotificationService:
         }
 
         title, message = await self._build_message_from_template(db, "ams_humidity_high", variables)
-        # Alarms always send immediately, bypassing digest mode
         await self._send_to_providers(
             providers,
             title,
@@ -2319,7 +2314,6 @@ class NotificationService:
             "ams_humidity_high",
             printer_id,
             printer_name,
-            force_immediate=True,
             variables=variables,
         )
 
@@ -2332,7 +2326,7 @@ class NotificationService:
         threshold: float,
         db: AsyncSession,
     ):
-        """Handle AMS high temperature alarm event. Always sends immediately (bypasses digest)."""
+        """Handle AMS high temperature alarm event. Sent immediately, and also in the daily digest if enabled."""
         providers = await self._get_providers_for_event(db, "on_ams_temperature_high", printer_id)
         if not providers:
             return
@@ -2345,7 +2339,6 @@ class NotificationService:
         }
 
         title, message = await self._build_message_from_template(db, "ams_temperature_high", variables)
-        # Alarms always send immediately, bypassing digest mode
         await self._send_to_providers(
             providers,
             title,
@@ -2354,7 +2347,6 @@ class NotificationService:
             "ams_temperature_high",
             printer_id,
             printer_name,
-            force_immediate=True,
             variables=variables,
         )
 
@@ -2370,7 +2362,8 @@ class NotificationService:
     ):
         """Handle automatic drying giving up on one AMS unit (#2770).
 
-        Sent immediately rather than folded into a digest: it reports that
+        Worth sending immediately (as every notification is; a daily digest only
+        repeats it): it reports that
         Bambuddy has STOPPED doing something, and a report of inaction that
         arrives with tomorrow's summary has already cost the user a day.
         """
@@ -2395,7 +2388,6 @@ class NotificationService:
             "ams_drying_suspended",
             printer_id,
             printer_name,
-            force_immediate=True,
             variables=variables,
         )
 
@@ -2408,7 +2400,7 @@ class NotificationService:
         threshold: float,
         db: AsyncSession,
     ):
-        """Handle AMS-HT high humidity alarm event. Always sends immediately (bypasses digest)."""
+        """Handle AMS-HT high humidity alarm event. Sent immediately, and also in the daily digest if enabled."""
         providers = await self._get_providers_for_event(db, "on_ams_ht_humidity_high", printer_id)
         if not providers:
             return
@@ -2422,7 +2414,6 @@ class NotificationService:
 
         # Use the same template as regular AMS (can create separate templates later if needed)
         title, message = await self._build_message_from_template(db, "ams_humidity_high", variables)
-        # Alarms always send immediately, bypassing digest mode
         await self._send_to_providers(
             providers,
             title,
@@ -2431,7 +2422,6 @@ class NotificationService:
             "ams_ht_humidity_high",
             printer_id,
             printer_name,
-            force_immediate=True,
             variables=variables,
         )
 
@@ -2444,7 +2434,7 @@ class NotificationService:
         threshold: float,
         db: AsyncSession,
     ):
-        """Handle AMS-HT high temperature alarm event. Always sends immediately (bypasses digest)."""
+        """Handle AMS-HT high temperature alarm event. Sent immediately, and also in the daily digest if enabled."""
         providers = await self._get_providers_for_event(db, "on_ams_ht_temperature_high", printer_id)
         if not providers:
             return
@@ -2458,7 +2448,6 @@ class NotificationService:
 
         # Use the same template as regular AMS (can create separate templates later if needed)
         title, message = await self._build_message_from_template(db, "ams_temperature_high", variables)
-        # Alarms always send immediately, bypassing digest mode
         await self._send_to_providers(
             providers,
             title,
@@ -2467,7 +2456,6 @@ class NotificationService:
             "ams_ht_temperature_high",
             printer_id,
             printer_name,
-            force_immediate=True,
             variables=variables,
         )
 
@@ -2507,7 +2495,8 @@ class NotificationService:
     ):
         """A Home Assistant sensor bound to a printer entered its alert state (#1148).
 
-        Sent immediately rather than folded into a digest: the case this exists
+        Worth sending immediately (as every notification is; a daily digest only
+        repeats it): the case this exists
         for is an enclosure door left open, which is only worth telling someone
         about while they can still act on it.
         """
@@ -2530,7 +2519,6 @@ class NotificationService:
             "ha_sensor_alert",
             printer_id,
             printer_name,
-            force_immediate=True,
             variables=variables,
         )
 
@@ -2543,7 +2531,7 @@ class NotificationService:
     ):
         """A Home Assistant sensor bound to a storage location entered its alert state (#2824).
 
-        Sent immediately rather than folded into a digest, for the same reason
+        Worth sending immediately, for the same reason
         as on_ha_sensor_alert above: this is the "drybox went stale" case, only
         worth acting on while the humidity/temperature is still climbing.
         """
@@ -2568,7 +2556,6 @@ class NotificationService:
             message,
             db,
             "location_ha_sensor_alert",
-            force_immediate=True,
             variables=variables,
         )
 

+ 0 - 11
backend/app/services/plate_detection.py

@@ -134,17 +134,6 @@ class PlateDetector:
                 paths.append(path)
         return paths
 
-    def _get_next_reference_slot(self, printer_id: int) -> Path:
-        """Get the path for the next reference image slot (cycles through slots)."""
-        _get_calibration_dir().mkdir(parents=True, exist_ok=True)
-        # Find first empty slot, or use oldest (slot 0) and shift others
-        for i in range(self.MAX_REFERENCES):
-            path = _get_calibration_dir() / f"printer_{printer_id}_ref_{i}.jpg"
-            if not path.exists():
-                return path
-        # All slots full - return slot 0 (will be overwritten, but we rotate first)
-        return _get_calibration_dir() / f"printer_{printer_id}_ref_0.jpg"
-
     def _rotate_references(self, printer_id: int) -> None:
         """Rotate references: delete oldest (0), shift others down."""
         # Delete slot 0

+ 0 - 37
backend/app/services/spoolman.py

@@ -36,34 +36,6 @@ TAG_REFUSAL_RETRY_SECONDS = 600
 BAMBU_WEIGHT_USED_BASELINE_KEY = "bambu_weight_used_baseline"
 
 
-@dataclass
-class SpoolmanSpool:
-    """Represents a spool in Spoolman."""
-
-    id: int
-    filament_id: int | None
-    remaining_weight: float | None
-    used_weight: float
-    first_used: str | None
-    last_used: str | None
-    location: str | None
-    lot_nr: str | None
-    comment: str | None
-    extra: dict | None  # Contains tag_uid in extra.tag
-
-
-@dataclass
-class SpoolmanFilament:
-    """Represents a filament type in Spoolman."""
-
-    id: int
-    name: str
-    vendor_id: int | None
-    material: str | None
-    color_hex: str | None
-    weight: float | None  # Net weight in grams
-
-
 @dataclass
 class AMSTray:
     """Represents an AMS tray with filament data from Bambu printer."""
@@ -1119,15 +1091,6 @@ class SpoolmanClient:
                         return spool
         return None
 
-    def _find_spool_by_location(self, location: str, cached_spools: list[dict] | None) -> dict | None:
-        """Return the spool at the exact location string, or None; fallback when RFID is unavailable."""
-        if not cached_spools:
-            return None
-        for spool in cached_spools:
-            if spool.get("location") == location:
-                return spool
-        return None
-
     async def find_spools_by_location_prefix(
         self, location_prefix: str, cached_spools: list[dict] | None = None
     ) -> list[dict]:

+ 3 - 139
backend/app/services/virtual_printer/mqtt_server.py

@@ -82,147 +82,11 @@ MODEL_PRODUCT_NAMES = {
 }
 
 
-class VirtualPrinterMQTTServer:
-    """MQTT broker that accepts connections from slicers.
-
-    This is a minimal MQTT broker implementation that:
-    - Accepts TLS connections on port 8883
-    - Authenticates with username 'bblp' and the configured access code
-    - Receives print commands on device/{serial}/request
-    - Can publish status on device/{serial}/report
-    """
-
-    def __init__(
-        self,
-        serial: str,
-        access_code: str,
-        cert_path: Path,
-        key_path: Path,
-        port: int = MQTT_PORT,
-        on_print_command: Callable[[str, dict], None] | None = None,
-    ):
-        """Initialize the MQTT server.
-
-        Args:
-            serial: Virtual printer serial number
-            access_code: Password for authentication
-            cert_path: Path to TLS certificate
-            key_path: Path to TLS private key
-            port: Port to listen on (default 8883)
-            on_print_command: Callback when print command received (filename, data)
-        """
-        self.serial = serial
-        self.access_code = access_code
-        self.cert_path = cert_path
-        self.key_path = key_path
-        self.port = port
-        self.on_print_command = on_print_command
-        self._running = False
-        self._broker = None
-        self._broker_task = None
-
-    async def start(self) -> None:
-        """Start the MQTT broker."""
-        if self._running:
-            return
-
-        # Try to import amqtt
-        try:
-            from amqtt.broker import Broker
-        except ImportError:
-            logger.error("amqtt not installed. Run: pip install amqtt")
-            return
-
-        logger.info("Starting virtual printer MQTT broker on port %s", self.port)
-
-        # Build broker configuration
-        config = {
-            "listeners": {
-                "default": {
-                    "type": "tcp",
-                    "bind": f"0.0.0.0:{self.port}",
-                    "ssl": "on",
-                    "certfile": str(self.cert_path),
-                    "keyfile": str(self.key_path),
-                },
-            },
-            "auth": {
-                "allow-anonymous": False,
-                "plugins": ["auth_custom"],
-            },
-            "topic-check": {
-                "enabled": False,  # Allow any topic
-            },
-        }
-
-        try:
-            self._running = True
-
-            # Create and start broker
-            self._broker = Broker(config)
-
-            # Register custom auth plugin
-            self._broker.plugins_manager.plugins_handlers["auth_custom"] = self._authenticate
-
-            # Start the broker
-            await self._broker.start()
-            logger.info("MQTT broker started on port %s", self.port)
-
-            # Keep running
-            while self._running:
-                await asyncio.sleep(1)
-
-        except OSError as e:
-            if e.errno == 98:  # Address already in use
-                logger.error("MQTT port %s is already in use", self.port)
-            else:
-                logger.error("MQTT broker error: %s", e)
-        except asyncio.CancelledError:
-            logger.debug("MQTT broker task cancelled")
-        except Exception as e:
-            logger.error("MQTT broker error: %s", e)
-        finally:
-            await self.stop()
-
-    async def _authenticate(self, session) -> bool:
-        """Authenticate MQTT connection.
-
-        Args:
-            session: MQTT session with username/password
-
-        Returns:
-            True if authentication successful
-        """
-        username = getattr(session, "username", None)
-        password = getattr(session, "password", None)
-
-        # Bambu slicers use 'bblp' as username and access code as password
-        if username == "bblp" and password == self.access_code:
-            logger.debug("MQTT client authenticated from %s", session.remote_address)
-            return True
-
-        logger.warning("MQTT auth failed for user '%s' from %s", username, session.remote_address)
-        return False
-
-    async def stop(self) -> None:
-        """Stop the MQTT broker."""
-        logger.info("Stopping MQTT broker")
-        self._running = False
-
-        if self._broker:
-            try:
-                await self._broker.shutdown()
-            except OSError as e:
-                logger.debug("Error shutting down MQTT broker: %s", e)
-            self._broker = None
-
-
 class SimpleMQTTServer:
-    """Simplified MQTT server using raw sockets.
+    """The virtual printer's MQTT server, on raw sockets.
 
-    This is a fallback implementation that handles basic MQTT protocol
-    without requiring the amqtt library. It's less feature-complete but
-    more lightweight.
+    Handles the part of the MQTT protocol slicers use with a printer; not a
+    general-purpose broker.
     """
 
     def __init__(

+ 0 - 669
backend/app/services/virtual_printer/tcp_proxy.py

@@ -14,7 +14,6 @@ rewrite the printer's real IP with the proxy's bind IP in MQTT payloads.
 
 import asyncio
 import logging
-import random
 import re
 import ssl
 import subprocess
@@ -24,44 +23,6 @@ from pathlib import Path
 logger = logging.getLogger(__name__)
 
 
-class _SessionReuseSSLContext:
-    """Proxy around SSLContext that injects a TLS session into wrap_bio().
-
-    vsFTPd (used by some Bambu printers like X1C) requires TLS session reuse
-    on FTP data channels — the data connection must reuse the TLS session from
-    the control channel. Without this, the printer rejects the data connection
-    with "522 SSL connection failed: session reuse required".
-
-    asyncio's open_connection() calls SSLContext.wrap_bio() internally but
-    doesn't expose a session parameter. This wrapper intercepts wrap_bio()
-    to inject the saved control-channel session, enabling session reuse.
-    """
-
-    def __init__(self, ctx: ssl.SSLContext, session: ssl.SSLSession) -> None:
-        object.__setattr__(self, "_ctx", ctx)
-        object.__setattr__(self, "_session", session)
-
-    def __getattr__(self, name: str) -> object:
-        return getattr(self._ctx, name)
-
-    def wrap_bio(
-        self,
-        incoming: ssl.MemoryBIO,
-        outgoing: ssl.MemoryBIO,
-        server_side: bool = False,
-        server_hostname: str | None = None,
-        **kwargs: object,
-    ) -> ssl.SSLObject:
-        return self._ctx.wrap_bio(
-            incoming,
-            outgoing,
-            server_side=server_side,
-            server_hostname=server_hostname,
-            session=self._session,
-            **kwargs,
-        )
-
-
 def detect_port_redirect(port: int) -> int | None:
     """Detect if iptables redirects a port to another port.
 
@@ -805,636 +766,6 @@ class TCPProxy:
         logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
 
 
-class FTPTLSProxy(TLSProxy):
-    """FTP-aware TLS proxy that handles passive data connections.
-
-    Extends TLSProxy to intercept PASV/EPSV responses on the FTP control
-    channel, dynamically create TLS data proxies on local ports, and rewrite
-    the responses so the slicer connects to the proxy instead of the printer.
-
-    Without this, FTP passive data connections bypass the proxy and go directly
-    to the printer, which fails when the slicer can't reach the printer's IP.
-    """
-
-    PASV_PORT_MIN = 50000
-    PASV_PORT_MAX = 50100
-
-    async def stop(self) -> None:
-        """Stop proxy and clean up data connection servers."""
-        # Cancel any pending auto_close timeouts so they don't outlive the
-        # proxy holding a dead server reference. Without this, up to 101
-        # tasks lingered for ~60 s after stop(), each gripping a server
-        # ref + an active_connections slot; under rapid mode-switch the
-        # ports stayed bound long enough to fail the next start().
-        for task in list(self._auto_close_tasks):
-            task.cancel()
-        if self._auto_close_tasks:
-            await asyncio.gather(*self._auto_close_tasks, return_exceptions=True)
-        self._auto_close_tasks.clear()
-        # Close all data servers
-        for server in list(self._data_servers):
-            try:
-                server.close()
-                await server.wait_closed()
-            except OSError:
-                pass  # Best-effort cleanup of data proxy servers
-        self._data_servers.clear()
-        await super().stop()
-
-    async def start(self) -> None:
-        """Start the FTP TLS proxy."""
-        self._data_servers: list[asyncio.Server] = []
-        self._auto_close_tasks: list[asyncio.Task] = []
-        await super().start()
-
-    async def _handle_client(
-        self,
-        client_reader: asyncio.StreamReader,
-        client_writer: asyncio.StreamWriter,
-    ) -> None:
-        """Handle FTP client with PASV/EPSV-aware response forwarding."""
-        peername = client_writer.get_extra_info("peername")
-        client_id = f"{peername[0]}:{peername[1]}" if peername else "unknown"
-
-        logger.info("%s proxy: client connected from %s", self.name, client_id)
-
-        if self.on_connect:
-            try:
-                self.on_connect(client_id)
-            except Exception:
-                pass  # Ignore connect callback errors; connection proceeds regardless
-
-        # Determine our local IP from the control connection socket
-        sockname = client_writer.get_extra_info("sockname")
-        local_ip = sockname[0] if sockname else "0.0.0.0"  # nosec B104
-        if local_ip in ("0.0.0.0", "::"):  # nosec B104
-            local_ip = "127.0.0.1"
-
-        # Connect to target printer with TLS
-        try:
-            printer_reader, printer_writer = await asyncio.wait_for(
-                asyncio.open_connection(
-                    self.target_host,
-                    self.target_port,
-                    ssl=self._client_ssl_context,
-                ),
-                timeout=10.0,
-            )
-            logger.info("%s proxy: connected to printer %s:%s", self.name, self.target_host, self.target_port)
-        except TimeoutError:
-            logger.error("%s proxy: timeout connecting to %s:%s", self.name, self.target_host, self.target_port)
-            client_writer.close()
-            await client_writer.wait_closed()
-            return
-        except ssl.SSLError as e:
-            logger.error(
-                "%s proxy: SSL error connecting to %s:%s: %s", self.name, self.target_host, self.target_port, e
-            )
-            client_writer.close()
-            await client_writer.wait_closed()
-            return
-        except OSError as e:
-            logger.error("%s proxy: failed to connect to %s:%s: %s", self.name, self.target_host, self.target_port, e)
-            client_writer.close()
-            await client_writer.wait_closed()
-            return
-
-        # Capture the TLS session from the control channel for data channel
-        # reuse. vsFTPd (X1C) requires require_ssl_reuse — the data connection
-        # must present the same TLS session as the control channel.
-        ctrl_ssl_object = printer_writer.get_extra_info("ssl_object")
-        ctrl_tls_session = ctrl_ssl_object.session if ctrl_ssl_object else None
-        if ctrl_tls_session:
-            logger.debug("%s proxy: captured TLS session for data channel reuse", self.name)
-
-        # Track data channel protection level per session.
-        # PROT C = cleartext data, PROT P = TLS data.
-        # Default to cleartext — many Bambu printers (A1, H2D) use PROT C.
-        # If the slicer sends PROT P, we switch to TLS for data connections.
-        session_state: dict[str, str | ssl.SSLSession] = {"prot": "C"}
-        if ctrl_tls_session:
-            session_state["tls_session"] = ctrl_tls_session
-
-        # Client→Printer: intercept EPSV and replace with PASV
-        # EPSV responses only contain a port (no IP), so the slicer reuses
-        # the control connection IP. If that IP is the real printer (via
-        # iptables REDIRECT), the data connection bypasses the proxy.
-        # PASV responses include an explicit IP that we can rewrite.
-        client_to_printer = asyncio.create_task(
-            self._forward_ftp_commands(client_reader, printer_writer, f"{client_id}→printer", session_state),
-            name=f"{self.name}_c2p_{client_id}",
-        )
-        # Printer→Client: intercept PASV/EPSV responses
-        printer_to_client = asyncio.create_task(
-            self._forward_ftp_control(printer_reader, client_writer, f"printer→{client_id}", local_ip, session_state),
-            name=f"{self.name}_p2c_{client_id}",
-        )
-
-        self._active_connections[client_id] = (client_to_printer, printer_to_client)
-
-        try:
-            done, pending = await asyncio.wait(
-                [client_to_printer, printer_to_client],
-                return_when=asyncio.FIRST_COMPLETED,
-            )
-            for task in pending:
-                task.cancel()
-                try:
-                    await task
-                except asyncio.CancelledError:
-                    pass  # Expected when cancelling the other forwarding direction
-
-        except Exception as e:
-            logger.debug("%s proxy connection error: %s", self.name, e)
-        finally:
-            self._active_connections.pop(client_id, None)
-
-            for writer in [client_writer, printer_writer]:
-                try:
-                    writer.close()
-                    await writer.wait_closed()
-                except OSError:
-                    pass  # Best-effort connection cleanup; peer may have disconnected
-
-            logger.info("%s proxy: client %s disconnected", self.name, client_id)
-
-            if self.on_disconnect:
-                try:
-                    self.on_disconnect(client_id)
-                except Exception:
-                    pass  # Ignore disconnect callback errors; cleanup continues
-
-    async def _forward_ftp_commands(
-        self,
-        reader: asyncio.StreamReader,
-        writer: asyncio.StreamWriter,
-        direction: str,
-        session_state: dict[str, str | ssl.SSLSession],
-    ) -> None:
-        """Forward FTP client commands, replacing EPSV with PASV.
-
-        EPSV responses only contain a port number — the client reuses the
-        control connection IP for data.  When the control IP is the real
-        printer (due to iptables REDIRECT), EPSV data connections bypass
-        the proxy.  PASV responses include an explicit IP that the proxy
-        can rewrite to its own address.
-
-        Also tracks PROT P/C commands to know whether data connections
-        should use TLS or cleartext.
-        """
-        buffer = b""
-        total_bytes = 0
-        try:
-            while self._running:
-                data = await reader.read(65536)
-                if not data:
-                    break
-
-                total_bytes += len(data)
-                buffer += data
-                output = b""
-
-                while b"\r\n" in buffer:
-                    idx = buffer.index(b"\r\n")
-                    line = buffer[:idx]
-                    buffer = buffer[idx + 2 :]
-
-                    cmd_upper = line.strip().upper()
-
-                    # Track PROT level for data channel encryption
-                    if cmd_upper == b"PROT P":
-                        session_state["prot"] = "P"
-                        logger.info("FTP data protection: PROT P (TLS)")
-                    elif cmd_upper == b"PROT C":
-                        session_state["prot"] = "C"
-                        logger.info("FTP data protection: PROT C (cleartext)")
-
-                    output += line + b"\r\n"
-
-                if output:
-                    writer.write(output)
-                    await writer.drain()
-
-                logger.debug("%s proxy %s: %s bytes", self.name, direction, len(data))
-
-        except asyncio.CancelledError:
-            pass  # Expected when the other forwarding direction closes first
-        except ConnectionResetError:
-            logger.debug("%s proxy %s: connection reset", self.name, direction)
-        except BrokenPipeError:
-            logger.debug("%s proxy %s: broken pipe", self.name, direction)
-        except OSError as e:
-            logger.debug("%s proxy %s error: %s", self.name, direction, e)
-
-        if buffer:
-            try:
-                writer.write(buffer)
-                await writer.drain()
-            except OSError:
-                pass  # Best-effort flush of remaining FTP command data
-
-        logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
-
-    async def _forward_ftp_control(
-        self,
-        reader: asyncio.StreamReader,
-        writer: asyncio.StreamWriter,
-        direction: str,
-        local_ip: str,
-        session_state: dict[str, str | ssl.SSLSession],
-    ) -> None:
-        """Forward FTP control channel responses, rewriting PASV/EPSV.
-
-        FTP control channel is line-based (\\r\\n terminated). We buffer data
-        and process complete lines, intercepting 227 (PASV) and 229 (EPSV)
-        responses to create local data proxies.
-        """
-        buffer = b""
-        total_bytes = 0
-
-        try:
-            while self._running:
-                data = await reader.read(65536)
-                if not data:
-                    break
-
-                total_bytes += len(data)
-                buffer += data
-                output = b""
-
-                # Process all complete lines
-                while b"\r\n" in buffer:
-                    idx = buffer.index(b"\r\n")
-                    line = buffer[:idx]
-                    buffer = buffer[idx + 2 :]
-
-                    rewritten = await self._maybe_rewrite_pasv(line, local_ip, session_state)
-                    output += rewritten + b"\r\n"
-
-                if output:
-                    writer.write(output)
-                    await writer.drain()
-
-                logger.debug("%s proxy %s: %s bytes", self.name, direction, len(data))
-
-        except asyncio.CancelledError:
-            pass  # Expected when the other forwarding direction closes first
-        except ConnectionResetError:
-            logger.debug("%s proxy %s: connection reset", self.name, direction)
-        except BrokenPipeError:
-            logger.debug("%s proxy %s: broken pipe", self.name, direction)
-        except OSError as e:
-            logger.debug("%s proxy %s error: %s", self.name, direction, e)
-
-        # Flush any remaining buffered data
-        if buffer:
-            try:
-                writer.write(buffer)
-                await writer.drain()
-            except OSError:
-                pass  # Best-effort flush of remaining FTP control data
-
-        logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
-
-    async def _maybe_rewrite_pasv(
-        self, line: bytes, local_ip: str, session_state: dict[str, str | ssl.SSLSession]
-    ) -> bytes:
-        """Rewrite PASV/EPSV response to point to a local data proxy."""
-        try:
-            text = line.decode("utf-8")
-        except UnicodeDecodeError:
-            return line
-
-        # 227 Entering Passive Mode (h1,h2,h3,h4,p1,p2)
-        if text.startswith("227 "):
-            match = re.search(r"\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)", text)
-            if match:
-                h1, h2, h3, h4, p1, p2 = (int(x) for x in match.groups())
-                printer_ip = f"{h1}.{h2}.{h3}.{h4}"
-                printer_port = p1 * 256 + p2
-
-                local_port = await self._create_data_proxy(printer_ip, printer_port, session_state)
-                if local_port:
-                    ip_parts = local_ip.split(".")
-                    lp1 = local_port // 256
-                    lp2 = local_port % 256
-                    rewritten = (
-                        f"227 Entering Passive Mode "
-                        f"({ip_parts[0]},{ip_parts[1]},{ip_parts[2]},{ip_parts[3]},{lp1},{lp2})"
-                    )
-                    logger.info("FTP PASV rewrite: %s:%s → %s:%s", printer_ip, printer_port, local_ip, local_port)
-                    return rewritten.encode("utf-8")
-                else:
-                    logger.error("FTP PASV: failed to create data proxy for %s:%s", printer_ip, printer_port)
-            else:
-                logger.warning("FTP PASV: 227 response didn't match expected format: %s", text[:100])
-
-        # 229 Entering Extended Passive Mode (|||port|)
-        elif text.startswith("229 "):
-            match = re.search(r"\(\|\|\|(\d+)\|\)", text)
-            if match:
-                printer_port = int(match.group(1))
-
-                local_port = await self._create_data_proxy(self.target_host, printer_port, session_state)
-                if local_port:
-                    rewritten = f"229 Entering Extended Passive Mode (|||{local_port}|)"
-                    logger.info("FTP EPSV rewrite: port %s → %s", printer_port, local_port)
-                    return rewritten.encode("utf-8")
-                else:
-                    logger.error("FTP EPSV: failed to create data proxy for port %s", printer_port)
-            else:
-                logger.warning("FTP EPSV: 229 response didn't match expected format: %s", text[:100])
-
-        return line
-
-    async def _create_data_proxy(
-        self, printer_ip: str, printer_port: int, session_state: dict[str, str | ssl.SSLSession]
-    ) -> int | None:
-        """Create a one-shot proxy for an FTP data connection.
-
-        Prefers the printer's original passive port so the port number stays
-        the same in the rewritten PASV/EPSV response.  This is critical when
-        the slicer's FTP bounce-attack protection overrides the IP in the PASV
-        response: the slicer connects to <control_IP>:<port>, and if iptables
-        REDIRECT maps that port to the local machine, the data proxy must be
-        listening on the *same* port number.
-
-        Falls back to a random port if the original is unavailable.
-
-        Uses TLS or cleartext based on the session's PROT level:
-        - PROT P: TLS on both slicer and printer data connections
-        - PROT C: cleartext on both sides (common for A1/H2D printers)
-
-        Returns the local port number, or None if binding failed.
-        """
-        use_tls = session_state.get("prot") == "P"
-        logger.info(
-            "FTP data proxy: creating data proxy for %s:%s (printer-side %s)",
-            printer_ip,
-            printer_port,
-            "TLS" if use_tls else "cleartext",
-        )
-
-        # Get control channel TLS session for data channel reuse
-        tls_session = session_state.get("tls_session") if use_tls else None
-
-        # Try the printer's original port first — this ensures the port
-        # matches even when bounce protection or iptables REDIRECT is in play.
-        try:
-            await self._start_data_proxy_server(printer_port, printer_ip, printer_port, use_tls, tls_session)
-            logger.info("FTP data proxy: using printer's port %s", printer_port)
-            return printer_port
-        except OSError as e:
-            logger.debug(
-                "FTP data proxy: printer port %s unavailable (%s), trying random",
-                printer_port,
-                e,
-            )
-
-        for _attempt in range(10):
-            port = random.randint(self.PASV_PORT_MIN, self.PASV_PORT_MAX)
-            try:
-                await self._start_data_proxy_server(port, printer_ip, printer_port, use_tls, tls_session)
-                logger.info("FTP data proxy: using random port %s", port)
-                return port
-            except OSError:
-                continue
-
-        logger.error("Failed to bind FTP data proxy port after 10 attempts")
-        return None
-
-    async def _start_data_proxy_server(
-        self,
-        port: int,
-        printer_ip: str,
-        printer_port: int,
-        use_tls: bool,
-        tls_session: ssl.SSLSession | None = None,
-    ) -> None:
-        """Start a one-shot server for one FTP data connection.
-
-        When the slicer connects, immediately connects to the printer's data
-        port and buffers any slicer data until the printer connection is ready.
-        This handles zero-byte uploads (verify_job) where the slicer closes
-        the data channel before a naive proxy would finish its TLS handshake.
-
-        The slicer-side listener is ALWAYS cleartext.  Even when the slicer
-        sends PROT P on the control channel, Bambu Studio does not perform
-        a TLS handshake on the data connection — it relies on the implicit
-        FTPS control channel for authentication and sends data unencrypted.
-
-        The printer-side outbound connection follows the PROT level:
-        - PROT P (use_tls=True): TLS to the printer's data port
-        - PROT C (use_tls=False): cleartext to the printer's data port
-
-        This mirrors the control channel's TLS-termination architecture.
-
-        Raises OSError if the port is already in use.
-        """
-        connected = asyncio.Event()
-        server_holder: list[asyncio.Server] = []
-
-        # Slicer side: ALWAYS cleartext — Bambu Studio does not do TLS on
-        # the data channel even after sending PROT P.
-        # Printer side: TLS if PROT P, cleartext if PROT C.
-        # For TLS data connections, wrap the SSL context to reuse the
-        # control channel's TLS session if available. vsFTPd (X1C) requires
-        # require_ssl_reuse — without this, data connections are rejected
-        # with "522 SSL connection failed: session reuse required".
-        if use_tls and tls_session:
-            client_ssl = _SessionReuseSSLContext(self._client_ssl_context, tls_session)
-            logger.debug("FTP data proxy: using TLS session reuse for port %s", port)
-        else:
-            client_ssl = self._client_ssl_context if use_tls else None
-
-        # Slicer side is ALWAYS cleartext — Bambu Studio does not do TLS on
-        # the data channel even after PROT P (confirmed for both H2D and X1C).
-        printer_mode = "TLS" if use_tls else "cleartext"
-
-        async def handle_data(
-            client_reader: asyncio.StreamReader,
-            client_writer: asyncio.StreamWriter,
-        ) -> None:
-            """Handle one FTP data connection, then close the server."""
-            peername = client_writer.get_extra_info("peername")
-            data_client = f"{peername[0]}:{peername[1]}" if peername else "unknown"
-            logger.info(
-                "FTP data proxy port %s (slicer=cleartext, printer=%s): client connected from %s, bridging to %s:%s",
-                port,
-                printer_mode,
-                data_client,
-                printer_ip,
-                printer_port,
-            )
-            connected.set()
-            # One-shot: close server after accepting first connection
-            if server_holder:
-                server_holder[0].close()
-
-            printer_writer = None
-            try:
-                # Buffer any slicer data while connecting to printer.
-                # This handles the race where the slicer sends data (or closes
-                # for zero-byte files) before the TLS handshake completes.
-                slicer_buffer = bytearray()
-                slicer_eof = False
-
-                async def buffer_slicer():
-                    nonlocal slicer_eof
-                    while True:
-                        chunk = await client_reader.read(65536)
-                        if not chunk:
-                            slicer_eof = True
-                            return
-                        slicer_buffer.extend(chunk)
-
-                buffer_task = asyncio.create_task(buffer_slicer())
-
-                # Connect to printer's data port
-                printer_reader, printer_writer = await asyncio.wait_for(
-                    asyncio.open_connection(printer_ip, printer_port, ssl=client_ssl),
-                    timeout=10.0,
-                )
-                logger.info(
-                    "FTP data proxy port %s (printer=%s): connected to printer %s:%s",
-                    port,
-                    printer_mode,
-                    printer_ip,
-                    printer_port,
-                )
-
-                # Stop buffering
-                buffer_task.cancel()
-                try:
-                    await buffer_task
-                except asyncio.CancelledError:
-                    pass
-
-                # Flush buffered slicer data to printer
-                logger.info(
-                    "FTP data proxy port %s: buffer=%s bytes, slicer_eof=%s",
-                    port,
-                    len(slicer_buffer),
-                    slicer_eof,
-                )
-                if slicer_buffer:
-                    printer_writer.write(bytes(slicer_buffer))
-                    await printer_writer.drain()
-
-                # Forward remaining slicer data to printer, then close the
-                # printer side to signal upload complete.
-                #
-                # Bambu Studio does NOT close the FTP data channel after sending
-                # STOR data — it keeps the connection open and waits for the
-                # printer to close its side + send 226 on the control channel.
-                # A naive bidirectional proxy deadlocks here because the proxy
-                # waits for the slicer EOF that never comes.
-                #
-                # Fix: read slicer data with an idle timeout. Once data has been
-                # received and the slicer goes quiet, close the printer side so
-                # the printer can send 226. For RETR (download), the printer
-                # sends data and closes — the slicer reads until EOF — so this
-                # unidirectional approach works for both directions.
-                total_c2p = len(slicer_buffer)
-                if not slicer_eof:
-                    # Read remaining slicer data with idle detection.
-                    # Must be short — Bambu Studio expects 226 almost instantly
-                    # after sending data. Too long and the slicer times out.
-                    idle_timeout = 0.3
-                    while True:
-                        try:
-                            chunk = await asyncio.wait_for(client_reader.read(65536), timeout=idle_timeout)
-                        except TimeoutError:
-                            if total_c2p > 0:
-                                # Slicer sent data then went idle — upload done
-                                logger.debug(
-                                    "FTP data proxy port %s: slicer idle after %s bytes, closing printer side",
-                                    port,
-                                    total_c2p,
-                                )
-                                break
-                            continue  # No data yet, keep waiting
-                        if not chunk:
-                            break  # Slicer closed
-                        printer_writer.write(chunk)
-                        await printer_writer.drain()
-                        total_c2p += len(chunk)
-
-                logger.debug("FTP proxy data_c2p: total %s bytes", total_c2p)
-
-                # Close printer side to signal upload complete.
-                # For TLS, close() sends close_notify which the printer treats
-                # as end-of-data. The printer then sends 226 on the control
-                # channel. For RETR, this is a no-op since the printer closes
-                # first and we'd have exited the loop above via EOF.
-                try:
-                    printer_writer.close()
-                    await printer_writer.wait_closed()
-                except OSError:
-                    pass
-
-                # Wait for 226 response to propagate through the FTP control
-                # channel before closing the slicer's data channel.
-                #
-                # Without this delay, the data channel FIN arrives at the
-                # slicer before the 226 response on the control channel.
-                # BambuStudio reacts to the data channel FIN within <1ms
-                # by sending QUIT + closing the control channel — before
-                # 226 arrives (~2-3ms network RTT). This causes verify_job
-                # to be treated as failed and shows the login modal.
-                #
-                # In a direct connection, the printer sends 226 AND closes
-                # the data channel simultaneously, so the slicer gets both
-                # at once. The delay here emulates that timing.
-                if total_c2p > 0:
-                    await asyncio.sleep(0.5)
-
-            except Exception as e:
-                logger.error("FTP data proxy port %s: error: %s", port, e)
-            finally:
-                for w in [client_writer, printer_writer]:
-                    if w:
-                        try:
-                            w.close()
-                            await w.wait_closed()
-                        except OSError:
-                            pass  # Best-effort data connection cleanup
-                logger.info("FTP data proxy port %s: connection closed", port)
-
-        server = await asyncio.start_server(
-            handle_data,
-            "0.0.0.0",  # nosec B104
-            port,
-            # No TLS on slicer side — Bambu Studio doesn't do TLS on data
-            # channel even after PROT P (confirmed by connection hang test).
-        )
-        server_holder.append(server)
-        self._data_servers.append(server)
-
-        # Auto-close after 60s if no connection arrives
-        async def auto_close() -> None:
-            try:
-                await asyncio.wait_for(connected.wait(), timeout=60.0)
-            except TimeoutError:
-                logger.debug("FTP data proxy on port %s timed out, closing", port)
-                try:
-                    server.close()
-                    await server.wait_closed()
-                except OSError:
-                    pass  # Best-effort timeout cleanup
-            finally:
-                if server in self._data_servers:
-                    self._data_servers.remove(server)
-
-        # Track the auto_close task so stop() can cancel it; otherwise the
-        # 60 s timeout would hold a server reference past proxy teardown.
-        ac_task = asyncio.create_task(auto_close(), name=f"ftp_data_timeout_{port}")
-        self._auto_close_tasks.append(ac_task)
-        ac_task.add_done_callback(lambda t, tasks=self._auto_close_tasks: tasks.remove(t) if t in tasks else None)
-
-        logger.debug("FTP data proxy: port %s → %s:%s", port, printer_ip, printer_port)
-
-
 class SlicerProxyManager:
     """Manages FTP and MQTT TLS proxies for a single printer target."""
 

+ 0 - 41
backend/tests/unit/services/test_email_service.py

@@ -12,7 +12,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.models.notification_template import NotificationTemplate
 from backend.app.services.email_service import (
-    create_password_reset_email_from_template,
     create_welcome_email_from_template,
     generate_secure_password,
     render_template,
@@ -68,46 +67,6 @@ class TestEmailTemplateFormatting:
         assert "Hello testuser!<br>" in html_body
         assert "Your password is: testpass123<br>" in html_body
 
-    @pytest.mark.asyncio
-    async def test_password_reset_email_newlines_converted_to_br(self):
-        """Verify that newlines in password reset email body are converted to <br> tags."""
-        # Mock database session
-        db = AsyncMock(spec=AsyncSession)
-
-        # Mock template with newlines
-        template = NotificationTemplate(
-            event_type="password_reset",
-            name="Password Reset",
-            title_template="{app_name} - Password Reset",
-            body_template="Hello {username},\n\nYour password has been reset.\nNew password: {password}\n\nLogin at: {login_url}",
-            is_default=True,
-        )
-
-        # Patch get_notification_template to return our template
-        with patch("backend.app.services.email_service.get_notification_template", return_value=template):
-            # Generate email
-            subject, text_body, html_body = await create_password_reset_email_from_template(
-                db=db,
-                username="testuser",
-                password="newpass456",
-                login_url="http://example.com/login",
-                app_name="TestApp",
-            )
-
-        # Verify subject
-        assert subject == "TestApp - Password Reset"
-
-        # Verify text body has newlines
-        assert "\n\n" in text_body
-        assert "Hello testuser," in text_body
-
-        # Verify HTML body has <br> tags
-        assert "<br>" in html_body
-        # Should not use white-space: pre-wrap
-        assert "white-space: pre-wrap" not in html_body
-        # Should have security alert
-        assert "Security Alert" in html_body
-
     @pytest.mark.asyncio
     async def test_email_header_padding(self):
         """Verify that email header has proper padding to prevent cutoff."""

+ 0 - 37
backend/tests/unit/services/test_notification_service.py

@@ -130,7 +130,6 @@ class TestNotificationService:
                 },
             )
             assert mock_send.await_args.args[4:7] == ("billing_charge_failed", 7, "Printer B")
-            assert mock_send.await_args.kwargs["force_immediate"] is True
 
     # ========================================================================
     # Tests for on_print_complete (status routing)
@@ -347,9 +346,6 @@ class TestNotificationService:
             )
 
             mock_send.assert_called_once()
-            # Verify force_immediate is True for alarms
-            call_kwargs = mock_send.call_args[1]
-            assert call_kwargs.get("force_immediate") is True
 
     @pytest.mark.asyncio
     async def test_on_ams_temperature_high_sends_notification(self, service, mock_provider, mock_db):
@@ -374,9 +370,6 @@ class TestNotificationService:
             )
 
             mock_send.assert_called_once()
-            # Verify force_immediate is True for alarms
-            call_kwargs = mock_send.call_args[1]
-            assert call_kwargs.get("force_immediate") is True
 
     @pytest.mark.asyncio
     async def test_ams_alarm_skipped_when_toggle_disabled(self, service, mock_provider, mock_db):
@@ -431,33 +424,6 @@ class TestNotificationService:
             # but internally it will queue instead of send immediately
             mock_send.assert_called_once()
 
-    @pytest.mark.asyncio
-    async def test_force_immediate_bypasses_digest(self, service, mock_provider, mock_db):
-        """Verify force_immediate=True bypasses digest mode."""
-        mock_provider.daily_digest_enabled = True
-        mock_provider.on_ams_humidity_high = True
-
-        with (
-            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
-            patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
-            patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
-        ):
-            mock_get.return_value = [mock_provider]
-            mock_build.return_value = ("Alert", "Alert message")
-
-            await service.on_ams_humidity_high(
-                printer_id=1,
-                printer_name="Test",
-                ams_label="AMS-A",
-                humidity=75.0,
-                threshold=60.0,
-                db=mock_db,
-            )
-
-            # Verify force_immediate is passed
-            call_kwargs = mock_send.call_args[1]
-            assert call_kwargs.get("force_immediate") is True
-
 
 class TestDigestModeAlwaysSendsImmediately:
     """CRITICAL: Tests that notifications always send immediately regardless of digest setting."""
@@ -2437,9 +2403,6 @@ class TestPlateNotEmptyNotifications:
 
             mock_get.assert_called_once()
             mock_send.assert_called_once()
-            # Verify force_immediate is True (critical alert)
-            call_kwargs = mock_send.call_args[1]
-            assert call_kwargs.get("force_immediate") is True
 
     @pytest.mark.asyncio
     async def test_on_plate_not_empty_skipped_when_disabled(self, service, mock_provider, mock_db):

+ 1 - 7
backend/tests/unit/services/test_p2s_accessory_fans.py

@@ -284,16 +284,10 @@ class TestLeftAuxFanCommand:
         assert mqtt_client.set_fan_speed(10, 204) is True
         assert sent == ["M106 P10 S204"]
 
-    def test_set_left_aux_fan_helper(self, mqtt_client, monkeypatch):
-        sent = []
-        monkeypatch.setattr(mqtt_client, "send_gcode", lambda g: sent.append(g) or True)
-        assert mqtt_client.set_left_aux_fan(255) is True
-        assert sent == ["M106 P10 S255"]
-
     def test_speed_clamped_to_255(self, mqtt_client, monkeypatch):
         sent = []
         monkeypatch.setattr(mqtt_client, "send_gcode", lambda g: sent.append(g) or True)
-        mqtt_client.set_left_aux_fan(999)
+        mqtt_client.set_fan_speed(10, 999)
         assert sent == ["M106 P10 S255"]
 
     def test_invalid_fan_index_rejected(self, mqtt_client, monkeypatch):

+ 6 - 6
backend/tests/unit/test_postgres_restore_drop_cascade.py

@@ -95,7 +95,7 @@ async def test_restore_drops_tables_with_cascade_not_metadata_drop_all():
             "backend.app.core.database._create_engine",
             new=MagicMock(return_value=mock_engine),
         ):
-            await settings_module._import_sqlite_to_postgres(sqlite_path, "postgresql+asyncpg://test/test")
+            await settings_module._import_sqlite_to_postgres(sqlite_path)
 
         # 1. CASCADE drop is emitted, hitting every public-schema table.
         cascade_drops = [s for s in executed_sql if "CASCADE" in s and "pg_tables" in s]
@@ -157,7 +157,7 @@ async def test_restore_cascade_drop_targets_only_public_schema():
             "backend.app.core.database._create_engine",
             new=MagicMock(return_value=mock_engine),
         ):
-            await settings_module._import_sqlite_to_postgres(sqlite_path, "postgresql+asyncpg://test/test")
+            await settings_module._import_sqlite_to_postgres(sqlite_path)
 
         cascade = next((s for s in executed_sql if "CASCADE" in s), None)
         assert cascade is not None
@@ -236,7 +236,7 @@ async def test_restore_drops_every_foreign_key_before_importing_rows():
             "backend.app.core.database._create_engine",
             new=MagicMock(return_value=_mock_pg_engine(executed_sql)),
         ):
-            await settings_module._import_sqlite_to_postgres(sqlite_path, "postgresql+asyncpg://test/test")
+            await settings_module._import_sqlite_to_postgres(sqlite_path)
 
         fk_drops = [i for i, s in enumerate(executed_sql) if "pg_constraint" in s and "DROP CONSTRAINT" in s]
         assert fk_drops, (
@@ -283,9 +283,9 @@ async def test_restore_never_mutates_the_process_wide_orm_metadata(create_all_fa
         with patch("backend.app.core.database._create_engine", new=MagicMock(return_value=engine)):
             if create_all_fails:
                 with pytest.raises(RuntimeError, match="create_all exploded"):
-                    await settings_module._import_sqlite_to_postgres(sqlite_path, "postgresql+asyncpg://test/test")
+                    await settings_module._import_sqlite_to_postgres(sqlite_path)
             else:
-                await settings_module._import_sqlite_to_postgres(sqlite_path, "postgresql+asyncpg://test/test")
+                await settings_module._import_sqlite_to_postgres(sqlite_path)
 
         assert _fk_names(table) == before, "The restore must not add or remove constraints on the shared ORM metadata"
     finally:
@@ -314,7 +314,7 @@ async def test_unrestorable_fk_is_reported_by_its_columns(caplog):
             patch("backend.app.core.database._create_engine", new=MagicMock(return_value=engine)),
             caplog.at_level(logging.INFO, logger="backend.app.api.routes.settings"),
         ):
-            await settings_module._import_sqlite_to_postgres(sqlite_path, "postgresql+asyncpg://test/test")
+            await settings_module._import_sqlite_to_postgres(sqlite_path)
 
         warning = next((r.getMessage() for r in caplog.records if r.levelno == logging.WARNING), None)
         assert warning is not None, "a failed FK restore must be reported"

+ 2 - 2
backend/tests/unit/test_restore_schema_compat.py

@@ -250,7 +250,7 @@ async def test_the_import_refuses_before_it_drops_anything(tmp_path):
         patch("backend.app.core.database._create_engine", new=create_engine),
         pytest.raises(BackupSchemaIncompatible),
     ):
-        await settings_module._import_sqlite_to_postgres(path, "postgresql+asyncpg://test/test")
+        await settings_module._import_sqlite_to_postgres(path)
 
     assert executed == [], f"SQL ran against the destination before the refusal: {executed}"
     create_engine.assert_not_called()
@@ -272,7 +272,7 @@ async def test_a_missing_defaulted_column_is_inserted_with_its_default(tmp_path)
 
     engine, executed = _mock_engine()
     with patch("backend.app.core.database._create_engine", new=MagicMock(return_value=engine)):
-        await settings_module._import_sqlite_to_postgres(path, "postgresql+asyncpg://test/test")
+        await settings_module._import_sqlite_to_postgres(path)
 
     inserts = [(sql, params) for sql, params in executed if sql.startswith("INSERT INTO cost_centers")]
     assert inserts, f"no INSERT was emitted: {[sql[:60] for sql, _ in executed]}"

+ 2 - 2
backend/tests/unit/test_route_auth_coverage.py

@@ -31,10 +31,10 @@ from backend.app.main import app
 # Substring patterns identifying auth-bearing callable qualnames in the
 # resolved Depends() tree. Inner functions returned by factories carry
 # the outer factory's name in their qualname (e.g.
-# ``require_permission.<locals>.permission_checker``), so a substring
+# ``require_permission_if_auth_enabled.<locals>.permission_checker``), so a substring
 # check is enough; we don't have to enumerate the inner names.
 _AUTH_QUALNAME_PATTERNS: tuple[str, ...] = (
-    "require_",  # require_permission, require_permission_if_auth_enabled, require_role, require_admin_*, require_auth_*, require_any_*, require_ownership_*, require_camera_stream_token_*, require_energy_cost_update
+    "require_",  # require_permission_if_auth_enabled, require_admin_*, require_auth_*, require_any_*, require_ownership_*, require_camera_stream_token_*, require_energy_cost_update
     "cloud_caller",  # cloud.py route-level dep
     "_cloud_api_key_gate",  # cloud.py router-level dep
     "resolve_api_key_cloud_owner",  # used by slicer routes that need the API key's owner

+ 16 - 0
frontend/knip.jsonc

@@ -0,0 +1,16 @@
+{
+  "$schema": "https://unpkg.com/knip@6/schema.json",
+  // The browser loads the service worker by URL (public/sw-register.js, which
+  // index.html includes), so no import leads to it.
+  "entry": ["public/sw.js"],
+  "ignore": [
+    // Tools run by hand (node scripts/...), not part of the app.
+    "scripts/**",
+    // Types the sibling vendor .js; TypeScript reads it for that import.
+    "src/lib/vendor/toolpathRenderer.d.ts"
+  ],
+  // Keeps browserslist's baseline data current; nothing imports it.
+  "ignoreDependencies": ["baseline-browser-mapping"],
+  // Unused exports and types are left out: most are only used in their own file.
+  "include": ["files", "dependencies", "devDependencies", "unlisted", "unresolved", "binaries"]
+}

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 709 - 158
frontend/package-lock.json


+ 2 - 2
frontend/package.json

@@ -14,13 +14,13 @@
     "test:coverage": "vitest run --coverage",
     "test:ui": "vitest --ui",
     "check:i18n": "node scripts/check-i18n-parity.mjs",
+    "check:knip": "knip",
     "check:baseline": "node scripts/check-browser-baseline.mjs"
   },
   "dependencies": {
     "@dnd-kit/core": "^6.3.1",
     "@dnd-kit/sortable": "^10.0.0",
     "@dnd-kit/utilities": "^3.2.2",
-    "@floating-ui/dom": "^1.7.5",
     "@tanstack/react-query": "^5.90.11",
     "@tiptap/extension-color": "^3.11.1",
     "@tiptap/extension-image": "^3.11.1",
@@ -77,13 +77,13 @@
     "@types/react-dom": "^19.2.3",
     "@vitejs/plugin-react": "^5.2.0",
     "@vitest/coverage-v8": "^4.1.11",
-    "autoprefixer": "^10.4.22",
     "baseline-browser-mapping": "^2.9.19",
     "eslint": "^9.39.1",
     "eslint-plugin-react-hooks": "^7.0.1",
     "eslint-plugin-react-refresh": "^0.4.24",
     "globals": "^16.5.0",
     "jsdom": "^25.0.0",
+    "knip": "^6.36.0",
     "msw": "^2.6.0",
     "postcss": "^8.5.6",
     "remark-parse": "^11.0.0",

+ 0 - 42
frontend/src/App.css

@@ -1,42 +0,0 @@
-#root {
-  max-width: 1280px;
-  margin: 0 auto;
-  padding: 2rem;
-  text-align: center;
-}
-
-.logo {
-  height: 6em;
-  padding: 1.5em;
-  will-change: filter;
-  transition: filter 300ms;
-}
-.logo:hover {
-  filter: drop-shadow(0 0 2em #646cffaa);
-}
-.logo.react:hover {
-  filter: drop-shadow(0 0 2em #61dafbaa);
-}
-
-@keyframes logo-spin {
-  from {
-    transform: rotate(0deg);
-  }
-  to {
-    transform: rotate(360deg);
-  }
-}
-
-@media (prefers-reduced-motion: no-preference) {
-  a:nth-of-type(2) .logo {
-    animation: logo-spin infinite 20s linear;
-  }
-}
-
-.card {
-  padding: 2em;
-}
-
-.read-the-docs {
-  color: #888;
-}

+ 0 - 64
frontend/src/components/icons/WifiSignal.tsx

@@ -1,64 +0,0 @@
-interface WifiSignalProps {
-  signal: number | null | undefined;  // dBm value
-  className?: string;
-}
-
-/**
- * WiFi signal icon with 4 bars that fill based on signal strength.
- * - 4 bars: >= -50 dBm (excellent)
- * - 3 bars: >= -60 dBm (good)
- * - 2 bars: >= -70 dBm (fair)
- * - 1 bar:  < -70 dBm (weak)
- * - 0 bars: no signal data
- */
-export function WifiSignal({ signal, className = "w-4 h-4" }: WifiSignalProps) {
-  let bars = 0;
-  if (signal != null) {
-    if (signal >= -50) bars = 4;
-    else if (signal >= -60) bars = 3;
-    else if (signal >= -70) bars = 2;
-    else bars = 1;
-  }
-
-  const activeColor = "#00ae42";  // bambu-green
-  const inactiveColor = "#4a4a4a";  // dark gray
-
-  return (
-    <svg
-      viewBox="0 0 24 24"
-      fill="none"
-      stroke="currentColor"
-      strokeWidth="2"
-      strokeLinecap="round"
-      strokeLinejoin="round"
-      className={className}
-    >
-      {/* Dot at bottom */}
-      <circle
-        cx="12"
-        cy="20"
-        r="1"
-        fill={bars >= 1 ? activeColor : inactiveColor}
-        stroke={bars >= 1 ? activeColor : inactiveColor}
-      />
-      {/* First arc (smallest) */}
-      <path
-        d="M8.5 16.5a5 5 0 0 1 7 0"
-        stroke={bars >= 2 ? activeColor : inactiveColor}
-        fill="none"
-      />
-      {/* Second arc */}
-      <path
-        d="M5 13a10 10 0 0 1 14 0"
-        stroke={bars >= 3 ? activeColor : inactiveColor}
-        fill="none"
-      />
-      {/* Third arc (largest) */}
-      <path
-        d="M1.5 9.5a15 15 0 0 1 21 0"
-        stroke={bars >= 4 ? activeColor : inactiveColor}
-        fill="none"
-      />
-    </svg>
-  );
-}

+ 0 - 810
frontend/src/pages/UsersPage.tsx

@@ -1,810 +0,0 @@
-import { useState, useEffect, useMemo } from 'react';
-import { useNavigate } from 'react-router-dom';
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { useTranslation } from 'react-i18next';
-import { X, Plus, Edit2, Trash2, Save, Loader2, Users as UsersIcon, Shield, ArrowLeft, RotateCcw } from 'lucide-react';
-import { api } from '../api/client';
-import type { UserCreate, UserUpdate, UserResponse } from '../api/client';
-import { useAuth } from '../contexts/AuthContext';
-import { useToast } from '../contexts/ToastContext';
-import { Button } from '../components/Button';
-import { Card, CardContent, CardHeader } from '../components/Card';
-import { ConfirmModal } from '../components/ConfirmModal';
-import { CreateUserAdvancedAuthModal } from '../components/CreateUserAdvancedAuthModal';
-import { LdapUserPicker } from '../components/LdapUserPicker';
-
-interface FormData extends UserCreate {
-  group_ids: number[];
-  confirmPassword: string;
-  email?: string;
-}
-
-export function UsersPage() {
-  const navigate = useNavigate();
-  const { t } = useTranslation();
-  const { user: currentUser, hasPermission } = useAuth();
-  const { showToast } = useToast();
-  const queryClient = useQueryClient();
-  const [showCreateModal, setShowCreateModal] = useState(false);
-  // Basic-mode (non-advanced-auth) modal: track which tab is active so the
-  // LDAP picker can replace the local form when LDAP is enabled.
-  const [basicCreateTab, setBasicCreateTab] = useState<'local' | 'ldap'>('local');
-  const [showEditModal, setShowEditModal] = useState(false);
-  const [editingUserId, setEditingUserId] = useState<number | null>(null);
-  const [deleteUserId, setDeleteUserId] = useState<number | null>(null);
-  const [formData, setFormData] = useState<FormData>({
-    username: '',
-    password: '',
-    email: '',
-    confirmPassword: '',
-    role: 'user',
-    group_ids: [],
-  });
-
-  // Check if advanced auth is enabled
-  const { data: advancedAuthStatus = { advanced_auth_enabled: false, smtp_configured: false } } = useQuery({
-    queryKey: ['advancedAuthStatus'],
-    queryFn: () => api.getAdvancedAuthStatus(),
-  });
-
-  // LDAP status — drives whether the LDAP tab is rendered in the create modal.
-  const { data: ldapStatus = { ldap_enabled: false, ldap_configured: false } } = useQuery({
-    queryKey: ['ldapStatus'],
-    queryFn: () => api.getLDAPStatus(),
-  });
-
-  // Close modal on Escape key
-  useEffect(() => {
-    const handleKeyDown = (e: KeyboardEvent) => {
-      if (e.key === 'Escape') {
-        if (showCreateModal) {
-          setShowCreateModal(false);
-          setBasicCreateTab('local');
-          setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
-        }
-        if (showEditModal) {
-          setShowEditModal(false);
-          setEditingUserId(null);
-          setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
-        }
-      }
-    };
-    window.addEventListener('keydown', handleKeyDown);
-    return () => window.removeEventListener('keydown', handleKeyDown);
-  }, [showCreateModal, showEditModal]);
-
-  const { data: users = [], isLoading } = useQuery({
-    queryKey: ['users'],
-    queryFn: () => api.getUsers(),
-    enabled: hasPermission('users:read'),
-  });
-
-  const { data: groups = [] } = useQuery({
-    queryKey: ['groups'],
-    queryFn: () => api.getGroups(),
-    enabled: hasPermission('groups:read'),
-  });
-
-  const createMutation = useMutation({
-    mutationFn: (data: UserCreate) => api.createUser(data),
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['users'] });
-      queryClient.invalidateQueries({ queryKey: ['groups'] });
-      setShowCreateModal(false);
-      setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
-      showToast(t('users.toast.created'));
-    },
-    onError: (error: Error) => {
-      showToast(error.message, 'error');
-    },
-  });
-
-  const updateMutation = useMutation({
-    mutationFn: ({ id, data }: { id: number; data: UserUpdate }) => api.updateUser(id, data),
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['users'] });
-      queryClient.invalidateQueries({ queryKey: ['groups'] });
-      setShowEditModal(false);
-      setEditingUserId(null);
-      setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
-      showToast(t('users.toast.updated'));
-    },
-    onError: (error: Error) => {
-      showToast(error.message, 'error');
-    },
-  });
-
-  const deleteMutation = useMutation({
-    mutationFn: (id: number) => api.deleteUser(id),
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['users'] });
-      showToast(t('users.toast.deleted'));
-    },
-    onError: (error: Error) => {
-      showToast(error.message, 'error');
-    },
-  });
-
-  const resetPasswordMutation = useMutation({
-    mutationFn: (userId: number) => api.resetUserPassword({ user_id: userId }),
-    onSuccess: (data) => {
-      showToast(data.message, 'success');
-    },
-    onError: (error: Error) => {
-      showToast(error.message, 'error');
-    },
-  });
-
-  // Validation for create user button
-  const isCreateButtonDisabled = useMemo(() => {
-    if (createMutation.isPending || !formData.username) {
-      return true;
-    }
-    if (advancedAuthStatus?.advanced_auth_enabled) {
-      // When advanced auth is enabled, require email (password is auto-generated)
-      return !formData.email;
-    }
-    // When advanced auth is disabled, require valid password
-    return !formData.password || formData.password !== formData.confirmPassword || formData.password.length < 6;
-  }, [
-    createMutation.isPending,
-    formData.username,
-    formData.email,
-    formData.password,
-    formData.confirmPassword,
-    advancedAuthStatus?.advanced_auth_enabled
-  ]);
-
-  const handleCreate = () => {
-    // Use the status from the query hook
-    const advancedAuthEnabled = advancedAuthStatus?.advanced_auth_enabled || false;
-
-    if (!formData.username) {
-      const errorMsg = t('users.toast.fillRequired');
-      showToast(errorMsg, 'error');
-      if (advancedAuthEnabled) {
-        console.error('[Advanced Auth] Create user failed: Username is required');
-      }
-      return;
-    }
-
-    // Email is required when advanced auth is enabled
-    if (advancedAuthEnabled && !formData.email) {
-      const errorMsg = 'Email is required when advanced authentication is enabled';
-      showToast(errorMsg, 'error');
-      console.error('[Advanced Auth] Create user failed: Email is required when advanced authentication is enabled');
-      return;
-    }
-
-    // Password validation only when advanced auth is disabled
-    if (!advancedAuthEnabled) {
-      if (!formData.password) {
-        showToast(t('users.toast.fillRequired'), 'error');
-        return;
-      }
-      if (formData.password !== formData.confirmPassword) {
-        showToast(t('users.toast.passwordsDoNotMatch'), 'error');
-        return;
-      }
-      if (formData.password.length < 6) {
-        showToast(t('users.toast.passwordTooShort'), 'error');
-        return;
-      }
-    }
-
-    createMutation.mutate({
-      username: formData.username,
-      password: advancedAuthEnabled ? undefined : formData.password,
-      email: formData.email || undefined,
-      role: formData.role,
-      group_ids: formData.group_ids.length > 0 ? formData.group_ids : undefined,
-    });
-  };
-
-  const handleUpdate = (id: number) => {
-    // Validate password confirmation if a new password is being set
-    if (formData.password) {
-      if (formData.password !== formData.confirmPassword) {
-        showToast(t('users.toast.passwordsDoNotMatch'), 'error');
-        return;
-      }
-      if (formData.password.length < 6) {
-        showToast(t('users.toast.passwordTooShort'), 'error');
-        return;
-      }
-    }
-    const updateData: UserUpdate = {
-      username: formData.username || undefined,
-      password: formData.password || undefined,
-      email: formData.email || undefined,
-      role: formData.role,
-      group_ids: formData.group_ids,
-    };
-    // Remove password if empty
-    if (!updateData.password) {
-      delete updateData.password;
-    }
-    // Remove email if empty
-    if (!updateData.email) {
-      delete updateData.email;
-    }
-    updateMutation.mutate({ id, data: updateData });
-  };
-
-  const handleDelete = (id: number) => {
-    setDeleteUserId(id);
-  };
-
-  const startEdit = (user: UserResponse) => {
-    setEditingUserId(user.id);
-    setFormData({
-      username: user.username,
-      password: '',
-      email: user.email || '',
-      confirmPassword: '',
-      role: user.role,
-      group_ids: user.groups?.map(g => g.id) || [],
-    });
-    setShowEditModal(true);
-  };
-
-  const closeEditModal = () => {
-    setShowEditModal(false);
-    setEditingUserId(null);
-    setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
-  };
-
-  const toggleGroup = (groupId: number) => {
-    setFormData(prev => ({
-      ...prev,
-      group_ids: prev.group_ids.includes(groupId)
-        ? prev.group_ids.filter(id => id !== groupId)
-        : [...prev.group_ids, groupId],
-    }));
-  };
-
-  if (!hasPermission('users:read')) {
-    return (
-      <div className="p-6">
-        <Card>
-          <CardContent className="py-6">
-            <div className="flex items-center gap-3 text-red-700 dark:text-red-400">
-              <Shield className="w-5 h-5" />
-              <p className="text-white">{t('users.noPermission')}</p>
-            </div>
-          </CardContent>
-        </Card>
-      </div>
-    );
-  }
-
-  return (
-    <div className="p-6">
-      <div className="flex justify-between items-center mb-6">
-        <div className="flex items-center gap-4">
-          <button
-            onClick={() => navigate('/settings?tab=users')}
-            className="p-2 rounded-lg bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
-            title={t('users.backToSettings')}
-          >
-            <ArrowLeft className="w-5 h-5" />
-          </button>
-          <div>
-            <h1 className="text-2xl font-bold text-white flex items-center gap-2">
-              <UsersIcon className="w-6 h-6 text-bambu-green" />
-              {t('users.title')}
-            </h1>
-            <p className="text-sm text-bambu-gray mt-1">
-              {t('users.subtitle')}
-            </p>
-          </div>
-        </div>
-        <Button
-          onClick={() => {
-            setShowCreateModal(true);
-            setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
-          }}
-        >
-          <Plus className="w-4 h-4" />
-          {t('users.createUser')}
-        </Button>
-      </div>
-
-      {isLoading ? (
-        <div className="flex items-center justify-center py-12">
-          <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
-        </div>
-      ) : (
-        <Card>
-          <div className="overflow-x-auto">
-            <table className="min-w-full divide-y divide-bambu-dark-tertiary">
-              <thead>
-                <tr>
-                  <th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
-                    {t('users.table.username')}
-                  </th>
-                  <th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
-                    {t('users.table.groups')}
-                  </th>
-                  <th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
-                    {t('users.table.status')}
-                  </th>
-                  <th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
-                    {t('users.table.actions')}
-                  </th>
-                </tr>
-              </thead>
-              <tbody className="divide-y divide-bambu-dark-tertiary">
-                {users.map((user) => (
-                  <tr key={user.id} className="hover:bg-bambu-dark-tertiary/50 transition-colors">
-                    <td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-white">
-                      {user.username}
-                    </td>
-                    <td className="px-6 py-4 text-sm">
-                      <div className="flex flex-wrap gap-1">
-                        {user.is_admin && (
-                          <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-300">
-                            {t('users.admin')}
-                          </span>
-                        )}
-                        {user.groups?.map(group => (
-                          <span
-                            key={group.id}
-                            className={`px-2 py-0.5 rounded-full text-xs font-medium ${
-                              group.name === 'Administrators'
-                                ? 'bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-300'
-                                : group.name === 'Operators'
-                                ? 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300'
-                                : group.name === 'Viewers'
-                                ? 'bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300'
-                                : 'bg-gray-500/20 text-gray-300'
-                            }`}
-                          >
-                            {group.name}
-                          </span>
-                        ))}
-                        {(!user.groups || user.groups.length === 0) && !user.is_admin && (
-                          <span className="text-bambu-gray">{t('users.noGroups')}</span>
-                        )}
-                      </div>
-                    </td>
-                    <td className="px-6 py-4 whitespace-nowrap text-sm">
-                      <span className={`px-3 py-1 rounded-full text-xs font-medium ${
-                        user.is_active
-                          ? 'bg-bambu-green/20 text-bambu-green'
-                          : 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400'
-                      }`}>
-                        {user.is_active ? t('users.active') : t('users.inactive')}
-                      </span>
-                    </td>
-                    <td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
-                      <div className="flex items-center gap-2">
-                        <Button
-                          size="sm"
-                          variant="ghost"
-                          onClick={() => startEdit(user)}
-                        >
-                          <Edit2 className="w-4 h-4" />
-                          {t('users.edit')}
-                        </Button>
-                        {user.id !== currentUser?.id && (
-                          <Button
-                            size="sm"
-                            variant="ghost"
-                            onClick={() => handleDelete(user.id)}
-                          >
-                            <Trash2 className="w-4 h-4" />
-                            {t('users.delete')}
-                          </Button>
-                        )}
-                        {advancedAuthStatus?.advanced_auth_enabled && user.email && user.id !== currentUser?.id && (
-                          <Button
-                            size="sm"
-                            variant="ghost"
-                            onClick={() => resetPasswordMutation.mutate(user.id)}
-                            disabled={resetPasswordMutation.isPending}
-                          >
-                            <RotateCcw className="w-4 h-4" />
-                            {t('users.form.resetPassword') || 'Reset Password'}
-                          </Button>
-                        )}
-                      </div>
-                    </td>
-                  </tr>
-                ))}
-              </tbody>
-            </table>
-          </div>
-        </Card>
-      )}
-
-      {/* Create User Modal */}
-      {showCreateModal && !advancedAuthStatus?.advanced_auth_enabled && (
-        <div
-          className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
-          onClick={() => {
-            setShowCreateModal(false);
-            setBasicCreateTab('local');
-            setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
-          }}
-        >
-          <Card
-            className="w-full max-w-md"
-            onClick={(e: React.MouseEvent) => e.stopPropagation()}
-          >
-            <CardHeader>
-              <div className="flex items-center justify-between">
-                <div className="flex items-center gap-2">
-                  <UsersIcon className="w-5 h-5 text-bambu-green" />
-                  <h2 className="text-lg font-semibold text-white">{t('users.modal.createUser')}</h2>
-                </div>
-                <Button
-                  variant="ghost"
-                  size="sm"
-                  onClick={() => {
-                    setShowCreateModal(false);
-                    setBasicCreateTab('local');
-                    setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
-                  }}
-                >
-                  <X className="w-5 h-5" />
-                </Button>
-              </div>
-            </CardHeader>
-            <CardContent>
-              {ldapStatus?.ldap_enabled && (
-                <div
-                  className="mb-4 flex items-center gap-1 p-1 bg-bambu-dark-secondary rounded-lg"
-                  role="tablist"
-                  aria-label={t('users.modal.tabsAriaLabel')}
-                >
-                  <button
-                    type="button"
-                    role="tab"
-                    aria-selected={basicCreateTab === 'local'}
-                    onClick={() => setBasicCreateTab('local')}
-                    className={`flex-1 px-3 py-2 text-sm rounded-md transition-colors ${
-                      basicCreateTab === 'local'
-                        ? 'bg-bambu-green/15 text-bambu-green'
-                        : 'text-bambu-gray hover:text-white'
-                    }`}
-                  >
-                    {t('users.modal.localTab')}
-                  </button>
-                  <button
-                    type="button"
-                    role="tab"
-                    aria-selected={basicCreateTab === 'ldap'}
-                    onClick={() => setBasicCreateTab('ldap')}
-                    className={`flex-1 px-3 py-2 text-sm rounded-md transition-colors ${
-                      basicCreateTab === 'ldap'
-                        ? 'bg-bambu-green/15 text-bambu-green'
-                        : 'text-bambu-gray hover:text-white'
-                    }`}
-                  >
-                    {t('users.modal.ldapTab')}
-                  </button>
-                </div>
-              )}
-
-              {basicCreateTab === 'ldap' && ldapStatus?.ldap_enabled ? (
-                <>
-                  <LdapUserPicker
-                    onSuccess={(user) => {
-                      setShowCreateModal(false);
-                      setBasicCreateTab('local');
-                      setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
-                      showToast(t('users.toast.ldapProvisioned', { username: user.username }));
-                    }}
-                  />
-                  <div className="mt-6 flex justify-end">
-                    <Button
-                      variant="secondary"
-                      onClick={() => {
-                        setShowCreateModal(false);
-                        setBasicCreateTab('local');
-                        setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
-                      }}
-                    >
-                      {t('users.modal.cancel')}
-                    </Button>
-                  </div>
-                </>
-              ) : (
-              <>
-              <div className="space-y-4">
-                <div>
-                  <label className="block text-sm font-medium text-white mb-2">
-                    {t('users.form.username')}
-                  </label>
-                  <input
-                    type="text"
-                    value={formData.username}
-                    onChange={(e) => setFormData({ ...formData, username: e.target.value })}
-                    className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
-                    placeholder={t('users.form.usernamePlaceholder')}
-                    autoComplete="username"
-                  />
-                </div>
-                <div>
-                  <label className="block text-sm font-medium text-white mb-2">
-                    {t('users.form.password')}
-                  </label>
-                  <input
-                    type="password"
-                    value={formData.password}
-                    onChange={(e) => setFormData({ ...formData, password: e.target.value })}
-                    className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
-                    placeholder={t('users.form.passwordPlaceholder')}
-                    autoComplete="new-password"
-                    minLength={6}
-                  />
-                </div>
-                <div>
-                  <label className="block text-sm font-medium text-white mb-2">
-                    {t('users.form.confirmPassword')}
-                  </label>
-                  <input
-                    type="password"
-                    value={formData.confirmPassword}
-                    onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
-                    className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${
-                      formData.confirmPassword && formData.password !== formData.confirmPassword
-                        ? 'border-red-500'
-                        : 'border-bambu-dark-tertiary'
-                    }`}
-                    placeholder={t('users.form.confirmPasswordPlaceholder')}
-                    autoComplete="new-password"
-                    minLength={6}
-                  />
-                  {formData.confirmPassword && formData.password !== formData.confirmPassword && (
-                    <p className="text-red-700 dark:text-red-400 text-xs mt-1">{t('users.toast.passwordsDoNotMatch')}</p>
-                  )}
-                </div>
-                <div>
-                  <label className="block text-sm font-medium text-white mb-2">
-                    {t('users.form.groups')}
-                  </label>
-                  <div className="space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
-                    {groups.map(group => (
-                      <label
-                        key={group.id}
-                        className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-tertiary cursor-pointer"
-                      >
-                        <input
-                          type="checkbox"
-                          checked={formData.group_ids.includes(group.id)}
-                          onChange={() => toggleGroup(group.id)}
-                          className="w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark"
-                        />
-                        <span className="text-sm text-white">{group.name}</span>
-                        {group.is_system && (
-                          <span className="text-xs text-yellow-700 dark:text-yellow-400">({t('users.system')})</span>
-                        )}
-                      </label>
-                    ))}
-                    {groups.length === 0 && (
-                      <p className="text-sm text-bambu-gray">{t('users.noGroupsAvailable')}</p>
-                    )}
-                  </div>
-                </div>
-              </div>
-              <div className="mt-6 flex justify-end gap-3">
-                <Button
-                  variant="secondary"
-                  onClick={() => {
-                    setShowCreateModal(false);
-                    setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
-                  }}
-                >
-                  {t('users.modal.cancel')}
-                </Button>
-                <Button
-                  onClick={handleCreate}
-                  disabled={isCreateButtonDisabled}
-                >
-                  {createMutation.isPending ? (
-                    <>
-                      <Loader2 className="w-4 h-4 animate-spin" />
-                      {t('users.modal.creating')}
-                    </>
-                  ) : (
-                    <>
-                      <Plus className="w-4 h-4" />
-                      {t('users.modal.createUser')}
-                    </>
-                  )}
-                </Button>
-              </div>
-              </>
-              )}
-            </CardContent>
-          </Card>
-        </div>
-      )}
-
-      {/* Create User Modal - Advanced Authentication */}
-      {showCreateModal && advancedAuthStatus?.advanced_auth_enabled && (
-        <CreateUserAdvancedAuthModal
-          formData={formData}
-          setFormData={setFormData}
-          groups={groups}
-          onClose={() => {
-            setShowCreateModal(false);
-            setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
-          }}
-          onCreate={handleCreate}
-          isCreating={createMutation.isPending}
-          isCreateButtonDisabled={isCreateButtonDisabled}
-          ldapEnabled={ldapStatus?.ldap_enabled}
-          onLdapProvisioned={(user) => {
-            setShowCreateModal(false);
-            setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
-            showToast(t('users.toast.ldapProvisioned', { username: user.username }));
-          }}
-        />
-      )}
-
-      {/* Edit User Modal */}
-      {showEditModal && editingUserId !== null && (
-        <div
-          className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
-          onClick={closeEditModal}
-        >
-          <Card
-            className="w-full max-w-md"
-            onClick={(e: React.MouseEvent) => e.stopPropagation()}
-          >
-            <CardHeader>
-              <div className="flex items-center justify-between">
-                <div className="flex items-center gap-2">
-                  <Edit2 className="w-5 h-5 text-bambu-green" />
-                  <h2 className="text-lg font-semibold text-white">{t('users.modal.editUser')}</h2>
-                </div>
-                <Button
-                  variant="ghost"
-                  size="sm"
-                  onClick={closeEditModal}
-                >
-                  <X className="w-5 h-5" />
-                </Button>
-              </div>
-            </CardHeader>
-            <CardContent>
-              <div className="space-y-4">
-                <div>
-                  <label className="block text-sm font-medium text-white mb-2">
-                    {t('users.form.username')}
-                  </label>
-                  <input
-                    type="text"
-                    value={formData.username}
-                    onChange={(e) => setFormData({ ...formData, username: e.target.value })}
-                    className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
-                    placeholder={t('users.form.usernamePlaceholder')}
-                    autoComplete="username"
-                  />
-                </div>
-                <div>
-                  <label className="block text-sm font-medium text-white mb-2">
-                    {t('users.form.email') || 'Email'} <span className="text-bambu-gray font-normal">({t('users.form.optional') || 'optional'})</span>
-                  </label>
-                  <input
-                    type="email"
-                    value={formData.email}
-                    onChange={(e) => setFormData({ ...formData, email: e.target.value })}
-                    className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
-                    placeholder={t('users.form.emailPlaceholder') || 'user@example.com'}
-                  />
-                </div>
-                <div>
-                  <label className="block text-sm font-medium text-white mb-2">
-                    {t('users.form.password')} <span className="text-bambu-gray font-normal">({t('users.form.leaveBlankToKeep')})</span>
-                  </label>
-                  <input
-                    type="password"
-                    value={formData.password}
-                    onChange={(e) => setFormData({ ...formData, password: e.target.value, confirmPassword: '' })}
-                    className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
-                    placeholder={t('users.form.newPasswordPlaceholder')}
-                    autoComplete="new-password"
-                    minLength={6}
-                  />
-                </div>
-                {formData.password && (
-                  <div>
-                    <label className="block text-sm font-medium text-white mb-2">
-                      {t('users.form.confirmPassword')}
-                    </label>
-                    <input
-                      type="password"
-                      value={formData.confirmPassword}
-                      onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
-                      className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${
-                        formData.confirmPassword && formData.password !== formData.confirmPassword
-                          ? 'border-red-500'
-                          : 'border-bambu-dark-tertiary'
-                      }`}
-                      placeholder={t('users.form.confirmNewPasswordPlaceholder')}
-                      autoComplete="new-password"
-                      minLength={6}
-                    />
-                    {formData.confirmPassword && formData.password !== formData.confirmPassword && (
-                      <p className="text-red-700 dark:text-red-400 text-xs mt-1">{t('users.toast.passwordsDoNotMatch')}</p>
-                    )}
-                  </div>
-                )}
-                <div>
-                  <label className="block text-sm font-medium text-white mb-2">
-                    {t('users.form.groups')}
-                  </label>
-                  <div className="space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
-                    {groups.map(group => (
-                      <label
-                        key={group.id}
-                        className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-tertiary cursor-pointer"
-                      >
-                        <input
-                          type="checkbox"
-                          checked={formData.group_ids.includes(group.id)}
-                          onChange={() => toggleGroup(group.id)}
-                          className="w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark"
-                        />
-                        <span className="text-sm text-white">{group.name}</span>
-                        {group.is_system && (
-                          <span className="text-xs text-yellow-700 dark:text-yellow-400">({t('users.system')})</span>
-                        )}
-                      </label>
-                    ))}
-                    {groups.length === 0 && (
-                      <p className="text-sm text-bambu-gray">{t('users.noGroupsAvailable')}</p>
-                    )}
-                  </div>
-                </div>
-              </div>
-              <div className="mt-6 flex justify-end gap-3">
-                <Button
-                  variant="secondary"
-                  onClick={closeEditModal}
-                >
-                  {t('users.modal.cancel')}
-                </Button>
-                <Button
-                  onClick={() => handleUpdate(editingUserId)}
-                  disabled={updateMutation.isPending || !formData.username || !!(formData.password && (formData.password !== formData.confirmPassword || formData.password.length < 6))}
-                >
-                  {updateMutation.isPending ? (
-                    <>
-                      <Loader2 className="w-4 h-4 animate-spin" />
-                      {t('users.modal.saving')}
-                    </>
-                  ) : (
-                    <>
-                      <Save className="w-4 h-4" />
-                      {t('users.modal.saveChanges')}
-                    </>
-                  )}
-                </Button>
-              </div>
-            </CardContent>
-          </Card>
-        </div>
-      )}
-
-      {/* Delete Confirmation Modal */}
-      {deleteUserId !== null && (
-        <ConfirmModal
-          title={t('users.deleteModal.title')}
-          message={t('users.deleteModal.message')}
-          confirmText={t('users.deleteModal.confirm')}
-          variant="danger"
-          onConfirm={() => {
-            deleteMutation.mutate(deleteUserId);
-            setDeleteUserId(null);
-          }}
-          onCancel={() => setDeleteUserId(null)}
-        />
-      )}
-    </div>
-  );
-}

+ 9 - 5
test_frontend.sh

@@ -1,5 +1,5 @@
 #!/bin/sh
-# Typecheck and lint run in parallel (both are read-only and independent),
+# Typecheck, lint and knip run in parallel (all read-only and independent),
 # then the tests. Exits non-zero if any step fails, so a type or lint error
 # can't scroll past unnoticed behind a green test run.
 
@@ -9,21 +9,25 @@ npm run typecheck > /tmp/bambuddy-typecheck.$$ 2>&1 &
 typecheck_pid=$!
 npm run lint > /tmp/bambuddy-lint.$$ 2>&1 &
 lint_pid=$!
+npm run check:knip > /tmp/bambuddy-knip.$$ 2>&1 &
+knip_pid=$!
 
 wait $typecheck_pid
 typecheck_rc=$?
 wait $lint_pid
 lint_rc=$?
+wait $knip_pid
+knip_rc=$?
 
-cat /tmp/bambuddy-typecheck.$$ /tmp/bambuddy-lint.$$
-rm -f /tmp/bambuddy-typecheck.$$ /tmp/bambuddy-lint.$$
+cat /tmp/bambuddy-typecheck.$$ /tmp/bambuddy-lint.$$ /tmp/bambuddy-knip.$$
+rm -f /tmp/bambuddy-typecheck.$$ /tmp/bambuddy-lint.$$ /tmp/bambuddy-knip.$$
 
 npm run test:run
 test_rc=$?
 
 cd ..
 
-if [ $typecheck_rc -ne 0 ] || [ $lint_rc -ne 0 ] || [ $test_rc -ne 0 ]; then
-    echo "FAILED: typecheck=$typecheck_rc lint=$lint_rc tests=$test_rc"
+if [ $typecheck_rc -ne 0 ] || [ $lint_rc -ne 0 ] || [ $knip_rc -ne 0 ] || [ $test_rc -ne 0 ]; then
+    echo "FAILED: typecheck=$typecheck_rc lint=$lint_rc knip=$knip_rc tests=$test_rc"
     exit 1
 fi

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů