Просмотр исходного кода

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 1 неделя назад
Родитель
Сommit
b5cd369f51
33 измененных файлов с 784 добавлено и 2710 удалено
  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
                     "lineCount": 1
                 }
                 }
             },
             },
-            {
-                "code": "reportAssignmentType",
-                "range": {
-                    "startColumn": 32,
-                    "endColumn": 50,
-                    "lineCount": 1
-                }
-            },
             {
             {
                 "code": "reportAssignmentType",
                 "code": "reportAssignmentType",
                 "range": {
                 "range": {
@@ -4261,38 +4253,6 @@
             }
             }
         ],
         ],
         "./backend/app/services/email_service.py": [
         "./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",
                 "code": "reportArgumentType",
                 "range": {
                 "range": {
@@ -6809,14 +6769,6 @@
             }
             }
         ],
         ],
         "./backend/app/services/virtual_printer/mqtt_server.py": [
         "./backend/app/services/virtual_printer/mqtt_server.py": [
-            {
-                "code": "reportMissingImports",
-                "range": {
-                    "startColumn": 17,
-                    "endColumn": 29,
-                    "lineCount": 1
-                }
-            },
             {
             {
                 "code": "reportGeneralTypeIssues",
                 "code": "reportGeneralTypeIssues",
                 "range": {
                 "range": {
@@ -6827,14 +6779,6 @@
             }
             }
         ],
         ],
         "./backend/app/services/virtual_printer/tcp_proxy.py": [
         "./backend/app/services/virtual_printer/tcp_proxy.py": [
-            {
-                "code": "reportAttributeAccessIssue",
-                "range": {
-                    "startColumn": 25,
-                    "endColumn": 33,
-                    "lineCount": 1
-                }
-            },
             {
             {
                 "code": "reportArgumentType",
                 "code": "reportArgumentType",
                 "range": {
                 "range": {
@@ -6899,30 +6843,6 @@
                     "lineCount": 1
                     "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",
                 "code": "reportRedeclaration",
                 "range": {
                 "range": {

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

@@ -270,6 +270,10 @@ jobs:
         working-directory: frontend
         working-directory: frontend
         run: npm run lint
         run: npm run lint
 
 
+      - name: Unused files and dependencies (knip)
+        working-directory: frontend
+        run: npm run check:knip
+
   frontend-security:
   frontend-security:
     name: Frontend Security
     name: Frontend Security
     runs-on: ubuntu-latest
     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)
 ### 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
 ```bash
 cd frontend
 cd frontend
@@ -235,8 +236,17 @@ npm run lint
 
 
 # Type check
 # Type check
 npm run typecheck
 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
 
 
 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:
 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:
 The easiest way to run tests is with the provided scripts in the project root:
 
 
 ```bash
 ```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_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_docker.sh      # Full Docker build, unit tests, and integration tests
 ./test_all.sh         # All of the above (frontend → backend → docker)
 ./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`):
 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
 - **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
 - **Docker**: Full image build, backend/frontend tests in Docker, integration health checks
 - **Security**: CodeQL analysis, dependency audits
 - **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 {}
     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.
     """Import data from a SQLite database file into the current PostgreSQL database.
 
 
     Used for cross-database restore (SQLite backup → PostgreSQL).
     Used for cross-database restore (SQLite backup → PostgreSQL).
@@ -1534,7 +1534,7 @@ async def restore_backup(
             else:
             else:
                 # Import SQLite backup into PostgreSQL
                 # Import SQLite backup into PostgreSQL
                 logger.info("Importing 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
             # 6. Replace data directories
             # For Docker compatibility: clear contents then copy (don't delete mount points)
             # 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``.
     ``resolve_apikey_owner``.
 
 
     By default ALL requested permissions must pass (mirrors
     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
     When ``require_any=True``, only one needs to pass (mirrors
     ``require_any_permission_if_auth_enabled``).
     ``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():
 def require_admin_if_auth_enabled():
     """Dependency factory that requires admin role if auth is 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
 # Convenience dependencies - these are functions that return Depends objects
-def RequireAdmin():
-    """Dependency that requires admin role."""
-    return Depends(require_role("admin"))
 
 
 
 
 def RequireAdminIfAuthEnabled():
 def RequireAdminIfAuthEnabled():
@@ -2170,84 +2153,6 @@ def RequireAdminIfAuthEnabled():
     return Depends(require_admin_if_auth_enabled())
     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):
 def require_permission_if_auth_enabled(*permissions: str | Permission):
     """Dependency factory that checks permissions only if auth is enabled.
     """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
     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):
 def RequirePermissionIfAuthEnabled(*permissions: str | Permission):
     """Convenience dependency that requires permissions if auth is enabled."""
     """Convenience dependency that requires permissions if auth is enabled."""
     return Depends(require_permission_if_auth_enabled(*permissions))
     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:
     if api_key.printer_ids is None:
         return ALL_PRINTERS
         return ALL_PRINTERS
     return PrinterScope(frozenset(int(pid) for pid in api_key.printer_ids))
     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.
         # The correct nozzle data comes from push_status response.
         return True
         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):
     def _prime_kprofile_request(self):
         """Send a priming K-profile request on connect.
         """Send a priming K-profile request on connect.
 
 
@@ -7502,22 +7488,6 @@ class BambuMQTTClient:
         speed = max(0, min(255, speed))  # Clamp to 0-255
         speed = max(0, min(255, speed))  # Clamp to 0-255
         return self.send_gcode(f"M106 P{fan} S{speed}")
         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:
     def set_airduct_mode(self, mode: str) -> bool:
         """Set air conditioning mode (cooling or heating).
         """Set air conditioning mode (cooling or heating).
 
 
@@ -7610,56 +7580,6 @@ class BambuMQTTClient:
         )
         )
         return True
         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:
     def ams_load_filament(self, tray_id: int, extruder_id: int | None = None) -> bool:
         """Load filament from a specific AMS tray.
         """Load filament from a specific AMS tray.
 
 
@@ -8315,53 +8235,6 @@ class BambuMQTTClient:
         self._client.publish(self.topic_publish, command_json, qos=1)
         self._client.publish(self.topic_publish, command_json, qos=1)
         return True
         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:
     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.
         """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)
     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:
 def _create_chamber_auth_payload(access_code: str) -> bytes:
     """Create the 80-byte authentication payload for chamber image protocol.
     """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
     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]:
 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)."""
     """Create a password-reset email that contains a secure link (not a plaintext password)."""
     subject = "BamBuddy - Password Reset Request"
     subject = "BamBuddy - Password Reset Request"
@@ -455,17 +381,11 @@ BamBuddy Team
 async def create_password_reset_link_email_from_template(
 async def create_password_reset_link_email_from_template(
     db: AsyncSession, username: str, reset_url: str
     db: AsyncSession, username: str, reset_url: str
 ) -> tuple[str, str, 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)
     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)
         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(
 async def send_user_print_notification(
     db: AsyncSession,
     db: AsyncSession,
     event_type: str,
     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
         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]:
 def list_usb_cameras() -> list[dict]:
     """List available USB cameras (V4L2 devices on Linux).
     """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}"
     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(
 def _extruder_side_for_ams(
     ams_id: int,
     ams_id: int,
     ams_extruder_map: dict[str, 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
         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:
 def _hex_code_label(rgba: str | None) -> str:
     """Format ``data.rgba`` as a printable ``#RRGGBB`` string for the label.
     """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)
     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:
 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."""
     """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()}
     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)
     logger.warning("No interface found for target IP %s", target_ip)
     return None
     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",
         event_type: str = "unknown",
         printer_id: int | None = None,
         printer_id: int | None = None,
         printer_name: str | None = None,
         printer_name: str | None = None,
-        force_immediate: bool = False,
         image_data: bytes | None = None,
         image_data: bytes | None = None,
         variables: dict | None = None,
         variables: dict | None = None,
     ):
     ):
@@ -2041,7 +2040,6 @@ class NotificationService:
             "print_missing_spool_assignment",
             "print_missing_spool_assignment",
             printer_id,
             printer_id,
             printer_name,
             printer_name,
-            force_immediate=True,
             variables=variables,
             variables=variables,
         )
         )
 
 
@@ -2074,7 +2072,6 @@ class NotificationService:
             "billing_charge_failed",
             "billing_charge_failed",
             printer_id,
             printer_id,
             printer_name,
             printer_name,
-            force_immediate=True,
             variables=variables,
             variables=variables,
         )
         )
 
 
@@ -2190,7 +2187,6 @@ class NotificationService:
             "plate_not_empty",
             "plate_not_empty",
             printer_id,
             printer_id,
             printer_name,
             printer_name,
-            force_immediate=True,
             image_data=image_data,
             image_data=image_data,
             variables=variables,
             variables=variables,
         )
         )
@@ -2297,7 +2293,7 @@ class NotificationService:
         threshold: float,
         threshold: float,
         db: AsyncSession,
         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)
         providers = await self._get_providers_for_event(db, "on_ams_humidity_high", printer_id)
         if not providers:
         if not providers:
             return
             return
@@ -2310,7 +2306,6 @@ class NotificationService:
         }
         }
 
 
         title, message = await self._build_message_from_template(db, "ams_humidity_high", variables)
         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(
         await self._send_to_providers(
             providers,
             providers,
             title,
             title,
@@ -2319,7 +2314,6 @@ class NotificationService:
             "ams_humidity_high",
             "ams_humidity_high",
             printer_id,
             printer_id,
             printer_name,
             printer_name,
-            force_immediate=True,
             variables=variables,
             variables=variables,
         )
         )
 
 
@@ -2332,7 +2326,7 @@ class NotificationService:
         threshold: float,
         threshold: float,
         db: AsyncSession,
         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)
         providers = await self._get_providers_for_event(db, "on_ams_temperature_high", printer_id)
         if not providers:
         if not providers:
             return
             return
@@ -2345,7 +2339,6 @@ class NotificationService:
         }
         }
 
 
         title, message = await self._build_message_from_template(db, "ams_temperature_high", variables)
         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(
         await self._send_to_providers(
             providers,
             providers,
             title,
             title,
@@ -2354,7 +2347,6 @@ class NotificationService:
             "ams_temperature_high",
             "ams_temperature_high",
             printer_id,
             printer_id,
             printer_name,
             printer_name,
-            force_immediate=True,
             variables=variables,
             variables=variables,
         )
         )
 
 
@@ -2370,7 +2362,8 @@ class NotificationService:
     ):
     ):
         """Handle automatic drying giving up on one AMS unit (#2770).
         """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
         Bambuddy has STOPPED doing something, and a report of inaction that
         arrives with tomorrow's summary has already cost the user a day.
         arrives with tomorrow's summary has already cost the user a day.
         """
         """
@@ -2395,7 +2388,6 @@ class NotificationService:
             "ams_drying_suspended",
             "ams_drying_suspended",
             printer_id,
             printer_id,
             printer_name,
             printer_name,
-            force_immediate=True,
             variables=variables,
             variables=variables,
         )
         )
 
 
@@ -2408,7 +2400,7 @@ class NotificationService:
         threshold: float,
         threshold: float,
         db: AsyncSession,
         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)
         providers = await self._get_providers_for_event(db, "on_ams_ht_humidity_high", printer_id)
         if not providers:
         if not providers:
             return
             return
@@ -2422,7 +2414,6 @@ class NotificationService:
 
 
         # Use the same template as regular AMS (can create separate templates later if needed)
         # 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)
         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(
         await self._send_to_providers(
             providers,
             providers,
             title,
             title,
@@ -2431,7 +2422,6 @@ class NotificationService:
             "ams_ht_humidity_high",
             "ams_ht_humidity_high",
             printer_id,
             printer_id,
             printer_name,
             printer_name,
-            force_immediate=True,
             variables=variables,
             variables=variables,
         )
         )
 
 
@@ -2444,7 +2434,7 @@ class NotificationService:
         threshold: float,
         threshold: float,
         db: AsyncSession,
         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)
         providers = await self._get_providers_for_event(db, "on_ams_ht_temperature_high", printer_id)
         if not providers:
         if not providers:
             return
             return
@@ -2458,7 +2448,6 @@ class NotificationService:
 
 
         # Use the same template as regular AMS (can create separate templates later if needed)
         # 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)
         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(
         await self._send_to_providers(
             providers,
             providers,
             title,
             title,
@@ -2467,7 +2456,6 @@ class NotificationService:
             "ams_ht_temperature_high",
             "ams_ht_temperature_high",
             printer_id,
             printer_id,
             printer_name,
             printer_name,
-            force_immediate=True,
             variables=variables,
             variables=variables,
         )
         )
 
 
@@ -2507,7 +2495,8 @@ class NotificationService:
     ):
     ):
         """A Home Assistant sensor bound to a printer entered its alert state (#1148).
         """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
         for is an enclosure door left open, which is only worth telling someone
         about while they can still act on it.
         about while they can still act on it.
         """
         """
@@ -2530,7 +2519,6 @@ class NotificationService:
             "ha_sensor_alert",
             "ha_sensor_alert",
             printer_id,
             printer_id,
             printer_name,
             printer_name,
-            force_immediate=True,
             variables=variables,
             variables=variables,
         )
         )
 
 
@@ -2543,7 +2531,7 @@ class NotificationService:
     ):
     ):
         """A Home Assistant sensor bound to a storage location entered its alert state (#2824).
         """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
         as on_ha_sensor_alert above: this is the "drybox went stale" case, only
         worth acting on while the humidity/temperature is still climbing.
         worth acting on while the humidity/temperature is still climbing.
         """
         """
@@ -2568,7 +2556,6 @@ class NotificationService:
             message,
             message,
             db,
             db,
             "location_ha_sensor_alert",
             "location_ha_sensor_alert",
-            force_immediate=True,
             variables=variables,
             variables=variables,
         )
         )
 
 

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

@@ -134,17 +134,6 @@ class PlateDetector:
                 paths.append(path)
                 paths.append(path)
         return paths
         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:
     def _rotate_references(self, printer_id: int) -> None:
         """Rotate references: delete oldest (0), shift others down."""
         """Rotate references: delete oldest (0), shift others down."""
         # Delete slot 0
         # 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"
 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
 @dataclass
 class AMSTray:
 class AMSTray:
     """Represents an AMS tray with filament data from Bambu printer."""
     """Represents an AMS tray with filament data from Bambu printer."""
@@ -1119,15 +1091,6 @@ class SpoolmanClient:
                         return spool
                         return spool
         return None
         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(
     async def find_spools_by_location_prefix(
         self, location_prefix: str, cached_spools: list[dict] | None = None
         self, location_prefix: str, cached_spools: list[dict] | None = None
     ) -> list[dict]:
     ) -> 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:
 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__(
     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 asyncio
 import logging
 import logging
-import random
 import re
 import re
 import ssl
 import ssl
 import subprocess
 import subprocess
@@ -24,44 +23,6 @@ from pathlib import Path
 logger = logging.getLogger(__name__)
 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:
 def detect_port_redirect(port: int) -> int | None:
     """Detect if iptables redirects a port to another port.
     """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)
         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:
 class SlicerProxyManager:
     """Manages FTP and MQTT TLS proxies for a single printer target."""
     """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.models.notification_template import NotificationTemplate
 from backend.app.services.email_service import (
 from backend.app.services.email_service import (
-    create_password_reset_email_from_template,
     create_welcome_email_from_template,
     create_welcome_email_from_template,
     generate_secure_password,
     generate_secure_password,
     render_template,
     render_template,
@@ -68,46 +67,6 @@ class TestEmailTemplateFormatting:
         assert "Hello testuser!<br>" in html_body
         assert "Hello testuser!<br>" in html_body
         assert "Your password is: testpass123<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
     @pytest.mark.asyncio
     async def test_email_header_padding(self):
     async def test_email_header_padding(self):
         """Verify that email header has proper padding to prevent cutoff."""
         """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.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)
     # Tests for on_print_complete (status routing)
@@ -347,9 +346,6 @@ class TestNotificationService:
             )
             )
 
 
             mock_send.assert_called_once()
             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
     @pytest.mark.asyncio
     async def test_on_ams_temperature_high_sends_notification(self, service, mock_provider, mock_db):
     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()
             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
     @pytest.mark.asyncio
     async def test_ams_alarm_skipped_when_toggle_disabled(self, service, mock_provider, mock_db):
     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
             # but internally it will queue instead of send immediately
             mock_send.assert_called_once()
             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:
 class TestDigestModeAlwaysSendsImmediately:
     """CRITICAL: Tests that notifications always send immediately regardless of digest setting."""
     """CRITICAL: Tests that notifications always send immediately regardless of digest setting."""
@@ -2437,9 +2403,6 @@ class TestPlateNotEmptyNotifications:
 
 
             mock_get.assert_called_once()
             mock_get.assert_called_once()
             mock_send.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
     @pytest.mark.asyncio
     async def test_on_plate_not_empty_skipped_when_disabled(self, service, mock_provider, mock_db):
     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 mqtt_client.set_fan_speed(10, 204) is True
         assert sent == ["M106 P10 S204"]
         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):
     def test_speed_clamped_to_255(self, mqtt_client, monkeypatch):
         sent = []
         sent = []
         monkeypatch.setattr(mqtt_client, "send_gcode", lambda g: sent.append(g) or True)
         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"]
         assert sent == ["M106 P10 S255"]
 
 
     def test_invalid_fan_index_rejected(self, mqtt_client, monkeypatch):
     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",
             "backend.app.core.database._create_engine",
             new=MagicMock(return_value=mock_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.
         # 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]
         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",
             "backend.app.core.database._create_engine",
             new=MagicMock(return_value=mock_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)
         cascade = next((s for s in executed_sql if "CASCADE" in s), None)
         assert cascade is not 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",
             "backend.app.core.database._create_engine",
             new=MagicMock(return_value=_mock_pg_engine(executed_sql)),
             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]
         fk_drops = [i for i, s in enumerate(executed_sql) if "pg_constraint" in s and "DROP CONSTRAINT" in s]
         assert fk_drops, (
         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)):
         with patch("backend.app.core.database._create_engine", new=MagicMock(return_value=engine)):
             if create_all_fails:
             if create_all_fails:
                 with pytest.raises(RuntimeError, match="create_all exploded"):
                 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:
             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"
         assert _fk_names(table) == before, "The restore must not add or remove constraints on the shared ORM metadata"
     finally:
     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)),
             patch("backend.app.core.database._create_engine", new=MagicMock(return_value=engine)),
             caplog.at_level(logging.INFO, logger="backend.app.api.routes.settings"),
             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)
         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"
         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),
         patch("backend.app.core.database._create_engine", new=create_engine),
         pytest.raises(BackupSchemaIncompatible),
         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}"
     assert executed == [], f"SQL ran against the destination before the refusal: {executed}"
     create_engine.assert_not_called()
     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()
     engine, executed = _mock_engine()
     with patch("backend.app.core.database._create_engine", new=MagicMock(return_value=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")]
     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]}"
     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
 # Substring patterns identifying auth-bearing callable qualnames in the
 # resolved Depends() tree. Inner functions returned by factories carry
 # resolved Depends() tree. Inner functions returned by factories carry
 # the outer factory's name in their qualname (e.g.
 # 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.
 # check is enough; we don't have to enumerate the inner names.
 _AUTH_QUALNAME_PATTERNS: tuple[str, ...] = (
 _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_caller",  # cloud.py route-level dep
     "_cloud_api_key_gate",  # cloud.py router-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
     "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"]
+}

Разница между файлами не показана из-за своего большого размера
+ 709 - 158
frontend/package-lock.json


+ 2 - 2
frontend/package.json

@@ -14,13 +14,13 @@
     "test:coverage": "vitest run --coverage",
     "test:coverage": "vitest run --coverage",
     "test:ui": "vitest --ui",
     "test:ui": "vitest --ui",
     "check:i18n": "node scripts/check-i18n-parity.mjs",
     "check:i18n": "node scripts/check-i18n-parity.mjs",
+    "check:knip": "knip",
     "check:baseline": "node scripts/check-browser-baseline.mjs"
     "check:baseline": "node scripts/check-browser-baseline.mjs"
   },
   },
   "dependencies": {
   "dependencies": {
     "@dnd-kit/core": "^6.3.1",
     "@dnd-kit/core": "^6.3.1",
     "@dnd-kit/sortable": "^10.0.0",
     "@dnd-kit/sortable": "^10.0.0",
     "@dnd-kit/utilities": "^3.2.2",
     "@dnd-kit/utilities": "^3.2.2",
-    "@floating-ui/dom": "^1.7.5",
     "@tanstack/react-query": "^5.90.11",
     "@tanstack/react-query": "^5.90.11",
     "@tiptap/extension-color": "^3.11.1",
     "@tiptap/extension-color": "^3.11.1",
     "@tiptap/extension-image": "^3.11.1",
     "@tiptap/extension-image": "^3.11.1",
@@ -77,13 +77,13 @@
     "@types/react-dom": "^19.2.3",
     "@types/react-dom": "^19.2.3",
     "@vitejs/plugin-react": "^5.2.0",
     "@vitejs/plugin-react": "^5.2.0",
     "@vitest/coverage-v8": "^4.1.11",
     "@vitest/coverage-v8": "^4.1.11",
-    "autoprefixer": "^10.4.22",
     "baseline-browser-mapping": "^2.9.19",
     "baseline-browser-mapping": "^2.9.19",
     "eslint": "^9.39.1",
     "eslint": "^9.39.1",
     "eslint-plugin-react-hooks": "^7.0.1",
     "eslint-plugin-react-hooks": "^7.0.1",
     "eslint-plugin-react-refresh": "^0.4.24",
     "eslint-plugin-react-refresh": "^0.4.24",
     "globals": "^16.5.0",
     "globals": "^16.5.0",
     "jsdom": "^25.0.0",
     "jsdom": "^25.0.0",
+    "knip": "^6.36.0",
     "msw": "^2.6.0",
     "msw": "^2.6.0",
     "postcss": "^8.5.6",
     "postcss": "^8.5.6",
     "remark-parse": "^11.0.0",
     "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
 #!/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
 # 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.
 # can't scroll past unnoticed behind a green test run.
 
 
@@ -9,21 +9,25 @@ npm run typecheck > /tmp/bambuddy-typecheck.$$ 2>&1 &
 typecheck_pid=$!
 typecheck_pid=$!
 npm run lint > /tmp/bambuddy-lint.$$ 2>&1 &
 npm run lint > /tmp/bambuddy-lint.$$ 2>&1 &
 lint_pid=$!
 lint_pid=$!
+npm run check:knip > /tmp/bambuddy-knip.$$ 2>&1 &
+knip_pid=$!
 
 
 wait $typecheck_pid
 wait $typecheck_pid
 typecheck_rc=$?
 typecheck_rc=$?
 wait $lint_pid
 wait $lint_pid
 lint_rc=$?
 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
 npm run test:run
 test_rc=$?
 test_rc=$?
 
 
 cd ..
 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
     exit 1
 fi
 fi

Некоторые файлы не были показаны из-за большого количества измененных файлов