Browse Source

Merge branch '0.1.6-final' into feature/printer_model_queue

MartinNYHC 3 months ago
parent
commit
ba3ad1d2f1
38 changed files with 827 additions and 1256 deletions
  1. 11 11
      .github/workflows/ci.yml
  2. 37 0
      .github/workflows/issue-closed.yml
  3. 0 1
      .github/workflows/stale.yml
  4. 21 1
      CHANGELOG.md
  5. 1 0
      README.md
  6. BIN
      backend/.coverage
  7. 36 2
      backend/app/api/routes/cloud.py
  8. 1 1
      backend/app/api/routes/printers.py
  9. 1 0
      backend/app/api/routes/settings.py
  10. 2 0
      backend/app/schemas/settings.py
  11. 1 8
      backend/app/services/github_backup.py
  12. 40 2
      backend/app/services/printer_manager.py
  13. 7 2
      backend/app/services/spoolman.py
  14. 22 0
      backend/tests/integration/test_settings_api.py
  15. 68 1
      backend/tests/unit/services/test_printer_manager.py
  16. 0 83
      bambuddy-issue-notes.txt
  17. 7 7
      deploy/bambuddy.service
  18. 1 1
      frontend/eslint.config.js
  19. 192 1058
      frontend/package-lock.json
  20. 2 2
      frontend/package.json
  21. 1 1
      frontend/src/__tests__/api/githubBackupApi.test.ts
  22. 1 0
      frontend/src/__tests__/components/Layout.test.tsx
  23. 40 0
      frontend/src/__tests__/pages/ArchivesPage.test.tsx
  24. 1 0
      frontend/src/__tests__/pages/FileManagerPage.test.tsx
  25. 21 0
      frontend/src/__tests__/pages/QueuePage.test.tsx
  26. 11 0
      frontend/src/__tests__/pages/SettingsPage.test.tsx
  27. 1 0
      frontend/src/__tests__/pages/StatsPage.test.tsx
  28. 5 0
      frontend/src/api/client.ts
  29. 15 7
      frontend/src/components/GitHubBackupSettings.tsx
  30. 84 3
      frontend/src/pages/ArchivesPage.tsx
  31. 10 5
      frontend/src/pages/PrintersPage.tsx
  32. 11 3
      frontend/src/pages/QueuePage.tsx
  33. 91 55
      frontend/src/pages/SettingsPage.tsx
  34. 82 0
      scripts/debug_preset.py
  35. 0 0
      static/assets/index-0Pk0mZuT.css
  36. 0 0
      static/assets/index-DpCP4Cea.js
  37. 2 2
      static/index.html
  38. 1 0
      test_frontend.sh

+ 11 - 11
.github/workflows/ci.yml

@@ -6,8 +6,8 @@ on:
   pull_request:
     # Run on all PRs, but skip for repo owner (runs local tests)
 
-# Skip CI for repo owner's PRs (they run tests locally)
-# This check is applied to all jobs below
+# Skip CI for PRs authored by repo owner (they run tests locally)
+# Uses PR author instead of triggering actor so rebasing by owner doesn't skip CI
 
 env:
   PYTHON_VERSION: '3.11'
@@ -30,7 +30,7 @@ jobs:
   backend-lint:
     name: Backend Lint
     runs-on: ubuntu-latest
-    if: github.event_name == 'push' || github.actor != github.repository_owner
+    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
     steps:
       - uses: actions/checkout@v4
 
@@ -51,7 +51,7 @@ jobs:
   backend-security:
     name: Backend Security
     runs-on: ubuntu-latest
-    if: github.event_name == 'push' || github.actor != github.repository_owner
+    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
     continue-on-error: true
     steps:
       - uses: actions/checkout@v4
@@ -73,7 +73,7 @@ jobs:
   backend-tests:
     name: Backend Tests
     runs-on: ubuntu-latest
-    if: github.event_name == 'push' || github.actor != github.repository_owner
+    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
     needs: backend-lint
     steps:
       - uses: actions/checkout@v4
@@ -110,7 +110,7 @@ jobs:
   frontend-lint:
     name: Frontend Lint
     runs-on: ubuntu-latest
-    if: github.event_name == 'push' || github.actor != github.repository_owner
+    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
     steps:
       - uses: actions/checkout@v4
 
@@ -132,7 +132,7 @@ jobs:
   frontend-security:
     name: Frontend Security
     runs-on: ubuntu-latest
-    if: github.event_name == 'push' || github.actor != github.repository_owner
+    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
     continue-on-error: true
     steps:
       - uses: actions/checkout@v4
@@ -155,7 +155,7 @@ jobs:
   frontend-typecheck:
     name: Frontend Type Check
     runs-on: ubuntu-latest
-    if: github.event_name == 'push' || github.actor != github.repository_owner
+    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
     steps:
       - uses: actions/checkout@v4
 
@@ -177,7 +177,7 @@ jobs:
   frontend-tests:
     name: Frontend Tests
     runs-on: ubuntu-latest
-    if: github.event_name == 'push' || github.actor != github.repository_owner
+    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
     needs: [frontend-lint, frontend-typecheck]
     steps:
       - uses: actions/checkout@v4
@@ -201,7 +201,7 @@ jobs:
   frontend-build:
     name: Frontend Build
     runs-on: ubuntu-latest
-    if: github.event_name == 'push' || github.actor != github.repository_owner
+    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
     needs: [frontend-tests]
     steps:
       - uses: actions/checkout@v4
@@ -228,7 +228,7 @@ jobs:
   docker-test:
     name: Docker Build
     runs-on: ubuntu-latest
-    if: github.event_name == 'push' || github.actor != github.repository_owner
+    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
     timeout-minutes: 20
     needs: [backend-tests, frontend-build]
     steps:

+ 37 - 0
.github/workflows/issue-closed.yml

@@ -0,0 +1,37 @@
+name: Clean up closed issues
+
+on:
+  issues:
+    types: [closed]
+
+permissions:
+  issues: write
+
+jobs:
+  remove-labels:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Remove feedback label
+        uses: actions/github-script@v7
+        with:
+          script: |
+            const issue = context.payload.issue;
+            const hasLabel = issue.labels.some(l => l.name === 'feedback');
+
+            if (hasLabel) {
+              try {
+                await github.rest.issues.removeLabel({
+                  owner: context.repo.owner,
+                  repo: context.repo.repo,
+                  issue_number: issue.number,
+                  name: 'feedback'
+                });
+                console.log(`Removed 'feedback' label from issue #${issue.number}`);
+              } catch (error) {
+                if (error.status === 404) {
+                  console.log(`Label 'feedback' already removed from issue #${issue.number}`);
+                } else {
+                  throw error;
+                }
+              }
+            }

+ 0 - 1
.github/workflows/stale.yml

@@ -18,4 +18,3 @@ jobs:
           days-before-stale: 21
           days-before-close: 7
           stale-issue-label: 'stale'
-          remove-issue-labels: 'feedback'

+ 21 - 1
CHANGELOG.md

@@ -2,9 +2,17 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
-## [0.1.6] - Not released
+## [0.1.6-final] - Not released
 
 ### New Features
+- **Disable Printer Firmware Checks** - New toggle in Settings → General → Updates to disable printer firmware update checks:
+  - Prevents Bambuddy from checking Bambu Lab servers for firmware updates
+  - Useful for users who prefer to manage firmware manually or have network restrictions
+- **Archive Plate Browsing** - Browse plate thumbnails directly in archive cards (Issue #166):
+  - Hover over archive card to reveal plate navigation for multi-plate files
+  - Left/right arrows to cycle through plate thumbnails
+  - Dot indicators show current plate (clickable to jump to specific plate)
+  - Lazy-loads plate data only when user hovers
 - **GitHub Profile Backup** - Automatically backup your Cloud profiles, K-profiles and settings to a GitHub repository:
   - Configure GitHub repository URL and Personal Access Token
   - Schedule backups hourly, daily, or weekly
@@ -102,6 +110,13 @@ All notable changes to Bambuddy will be documented in this file.
   - New "Print Queue" section in notification provider settings
 
 ### Fixes
+- **Multi-Plate Thumbnail in Queue** - Fixed queue items showing wrong thumbnail for multi-plate files (Issue #166):
+  - Queue now displays the correct plate thumbnail based on selected plate
+  - Previously always showed plate 1 thumbnail regardless of selection
+- **A1/A1 Mini Shows Printing Instead of Idle** - Fixed incorrect status display for A1 series printers (Issue #168):
+  - Some A1/A1 Mini firmware versions incorrectly report stage 0 ("Printing") when idle
+  - Now checks gcode_state to correctly display "Idle" for affected printers
+  - Fix only applies to A1 models with the specific buggy condition
 - **HMS Error Notifications** - Get notified when printer errors occur (Issue #84):
   - Automatic notifications for HMS errors (AMS issues, nozzle problems, etc.)
   - Human-readable error messages (853 error codes translated)
@@ -132,6 +147,11 @@ All notable changes to Bambuddy will be documented in this file.
   - Text wrap toggle: "Wrap" button in header to wrap long names instead of truncating
   - Both settings persist in localStorage
   - Tooltip shows full name on hover
+- **K-Profiles Backup Status** - Fixed GitHub backup settings showing incorrect printer connection count (e.g., "1/2 connected" when both printers are connected); now fetches status from API instead of relying on WebSocket cache
+- **GitHub Backup Timestamps** - Removed volatile timestamps from GitHub backup files so git diffs only show actual data changes
+
+### Maintenance
+- Upgraded vitest from 2.x to 3.x to resolve npm audit security vulnerabilities in dev dependencies
 
 ## [0.1.6b11] - 2026-01-22
 

+ 1 - 0
README.md

@@ -52,6 +52,7 @@
 - Photo attachments & failure analysis
 - Timelapse editor (trim, speed, music)
 - Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support)
+- Plate thumbnail browsing for multi-plate archives (hover to navigate between plates)
 - Archive comparison (side-by-side diff)
 
 ### 📊 Monitoring & Control

BIN
backend/.coverage


+ 36 - 2
backend/app/api/routes/cloud.py

@@ -267,6 +267,34 @@ _filament_cache_time: float = 0
 FILAMENT_CACHE_TTL = 300  # 5 minutes
 
 
+def _filament_id_to_setting_id(filament_id: str) -> str:
+    """
+    Convert filament_id to setting_id format for Bambu Cloud API.
+
+    Printers report filament_id (e.g., GFA00, GFG02) but the API expects
+    setting_id format which has an "S" inserted after "GF" (e.g., GFSA00, GFSG02).
+
+    User presets (starting with "P") and already-correct IDs are returned unchanged.
+    """
+    if not filament_id:
+        return filament_id
+
+    # User presets start with "P" - leave unchanged
+    if filament_id.startswith("P"):
+        return filament_id
+
+    # Official Bambu presets: GFx## -> GFSx##
+    # Check if it matches the filament_id pattern (GF followed by letter and digits)
+    if filament_id.startswith("GF") and len(filament_id) >= 4:
+        # Check if it's already a setting_id (has S after GF)
+        if filament_id[2] == "S":
+            return filament_id
+        # Insert "S" after "GF": GFA00 -> GFSA00
+        return f"GFS{filament_id[2:]}"
+
+    return filament_id
+
+
 @router.post("/filament-info")
 async def get_filament_info(setting_ids: list[str] = Body(...), db: AsyncSession = Depends(get_db)):
     """
@@ -308,7 +336,10 @@ async def get_filament_info(setting_ids: list[str] = Body(...), db: AsyncSession
             continue
 
         try:
-            data = await cloud.get_setting_detail(setting_id)
+            # Transform filament_id to setting_id format (GFA00 -> GFSA00)
+            api_setting_id = _filament_id_to_setting_id(setting_id)
+
+            data = await cloud.get_setting_detail(api_setting_id)
             setting = data.get("setting", {})
 
             # Extract name (e.g., "Bambu PLA Basic Jade White")
@@ -323,11 +354,14 @@ async def get_filament_info(setting_ids: list[str] = Body(...), db: AsyncSession
                     k_value = None
 
             info = {"name": name, "k": k_value}
+            # Cache using original ID so frontend gets expected response
             _filament_cache[setting_id] = info
             result[setting_id] = info
 
         except Exception as e:
-            logger.warning(f"Failed to get cloud preset {setting_id}: {e}")
+            logger.warning(
+                f"Failed to get cloud preset {setting_id} (API ID: {_filament_id_to_setting_id(setting_id)}): {e}"
+            )
             # Cache the failure to avoid repeated requests
             _filament_cache[setting_id] = {"name": "", "k": None}
             result[setting_id] = {"name": "", "k": None}

+ 1 - 1
backend/app/api/routes/printers.py

@@ -408,7 +408,7 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
         nozzles=nozzles,
         print_options=print_options,
         stg_cur=state.stg_cur,
-        stg_cur_name=get_derived_status_name(state),
+        stg_cur_name=get_derived_status_name(state, printer.model),
         stg=state.stg,
         airduct_mode=state.airduct_mode,
         speed_level=state.speed_level,

+ 1 - 0
backend/app/api/routes/settings.py

@@ -74,6 +74,7 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
                 "capture_finish_photo",
                 "spoolman_enabled",
                 "check_updates",
+                "check_printer_firmware",
                 "virtual_printer_enabled",
                 "ftp_retry_enabled",
                 "mqtt_enabled",

+ 2 - 0
backend/app/schemas/settings.py

@@ -26,6 +26,7 @@ class AppSettings(BaseModel):
 
     # Updates
     check_updates: bool = Field(default=True, description="Automatically check for updates on startup")
+    check_printer_firmware: bool = Field(default=True, description="Check for printer firmware updates from Bambu Lab")
 
     # Language
     notification_language: str = Field(default="en", description="Language for push notifications (en, de)")
@@ -134,6 +135,7 @@ class AppSettingsUpdate(BaseModel):
     spoolman_url: str | None = None
     spoolman_sync_mode: str | None = None
     check_updates: bool | None = None
+    check_printer_firmware: bool | None = None
     notification_language: str | None = None
     ams_humidity_good: int | None = None
     ams_humidity_fair: int | None = None

+ 1 - 8
backend/app/services/github_backup.py

@@ -309,13 +309,11 @@ class GitHubBackupService:
         }
         """
         files: dict[str, dict | list] = {}
-        now = datetime.now(UTC)
 
-        # Metadata file
+        # Metadata file (no timestamps - git tracks file history)
         metadata = {
             "version": "1.0",
             "backup_type": "bambuddy_profiles",
-            "created_at": now.isoformat(),
             "contents": {
                 "kprofiles": config.backup_kprofiles,
                 "cloud_profiles": config.backup_cloud_profiles,
@@ -365,7 +363,6 @@ class GitHubBackupService:
                             "printer_name": printer.name,
                             "printer_serial": serial,
                             "nozzle_diameter": nozzle,
-                            "exported_at": datetime.now(UTC).isoformat(),
                             "profiles": [
                                 {
                                     "slot_id": p.slot_id,
@@ -425,21 +422,18 @@ class GitHubBackupService:
             if filament_settings:
                 files["cloud_profiles/filament.json"] = {
                     "version": "1.0",
-                    "exported_at": datetime.now(UTC).isoformat(),
                     "profiles": filament_settings,
                 }
 
             if printer_settings:
                 files["cloud_profiles/printer.json"] = {
                     "version": "1.0",
-                    "exported_at": datetime.now(UTC).isoformat(),
                     "profiles": printer_settings,
                 }
 
             if process_settings:
                 files["cloud_profiles/process.json"] = {
                     "version": "1.0",
-                    "exported_at": datetime.now(UTC).isoformat(),
                     "profiles": process_settings,
                 }
 
@@ -462,7 +456,6 @@ class GitHubBackupService:
 
         files["settings/app_settings.json"] = {
             "version": "1.0",
-            "exported_at": datetime.now(UTC).isoformat(),
             "settings": settings_data,
         }
 

+ 40 - 2
backend/app/services/printer_manager.py

@@ -33,6 +33,22 @@ CHAMBER_TEMP_SUPPORTED_MODELS = frozenset(
     ]
 )
 
+# Models that may incorrectly report stg_cur=0 when idle (firmware bug)
+# Based on Home Assistant Bambu Lab integration observations
+# See: https://github.com/greghesp/ha-bambulab/blob/main/custom_components/bambu_lab/pybambu/models.py
+A1_MODELS = frozenset(
+    [
+        # Display names
+        "A1",
+        "A1 MINI",
+        "A1-MINI",
+        "A1MINI",
+        # Internal codes (from MQTT/SSDP)
+        "N1",  # A1 Mini
+        "N2S",  # A1
+    ]
+)
+
 
 def supports_chamber_temp(model: str | None) -> bool:
     """Check if a printer model has a real chamber temperature sensor.
@@ -47,6 +63,19 @@ def supports_chamber_temp(model: str | None) -> bool:
     return model_upper in CHAMBER_TEMP_SUPPORTED_MODELS
 
 
+def has_stg_cur_idle_bug(model: str | None) -> bool:
+    """Check if a printer model may incorrectly report stg_cur=0 when idle.
+
+    Some A1/A1 Mini firmware versions report stg_cur=0 (which maps to "Printing")
+    even when the printer is idle. This is a known firmware bug that was observed
+    in the Home Assistant Bambu Lab integration.
+    """
+    if not model:
+        return False
+    model_upper = model.strip().upper()
+    return model_upper in A1_MODELS
+
+
 class PrinterInfo:
     """Basic printer info for callbacks."""
 
@@ -373,13 +402,22 @@ class PrinterManager:
         return result
 
 
-def get_derived_status_name(state: PrinterState) -> str | None:
+def get_derived_status_name(state: PrinterState, model: str | None = None) -> str | None:
     """
     Compute a human-readable status name based on printer state.
 
     Uses stg_cur when available, otherwise derives status from temperature data
     when the printer is heating before a print starts.
+
+    Args:
+        state: The printer state to analyze
+        model: Optional printer model for model-specific workarounds
     """
+    # A1/A1 Mini firmware bug: some versions report stg_cur=0 when idle
+    # Only correct this specific case (IDLE + stg_cur=0) for affected models
+    if state.state == "IDLE" and state.stg_cur == 0 and has_stg_cur_idle_bug(model):
+        return None
+
     # If we have a valid calibration stage, use it
     # X1 models use -1 for idle, A1/P1 models use 255 for idle
     # Valid stage numbers are 0-254
@@ -581,7 +619,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
         "wifi_signal": state.wifi_signal,
         # Calibration stage tracking
         "stg_cur": state.stg_cur,
-        "stg_cur_name": get_derived_status_name(state),
+        "stg_cur_name": get_derived_status_name(state, model),
         # Printable objects count for skip objects feature
         "printable_objects_count": len(state.printable_objects),
         # Fan speeds (0-100 percentage, None if not available)

+ 7 - 2
backend/app/services/spoolman.py

@@ -541,10 +541,15 @@ class SpoolmanClient:
 
         # Need valid color to create filament
         tray_color = tray_data.get("tray_color", "")
-        if not tray_color or tray_color in ("", "00000000"):
-            logger.debug(f"Skipping tray with invalid color: {tray_color}")
+        if not tray_color or tray_color.strip() == "":
+            logger.debug("Skipping tray with empty color")
             return None
 
+        # Handle transparent/natural filament (RRGGBBAA with alpha=00)
+        # Replace with cream color that represents how natural PLA actually looks
+        if tray_color == "00000000":
+            tray_color = "F5E6D3FF"  # Light cream/natural color
+
         # Get sub_brands, falling back to tray_type
         tray_sub_brands = tray_data.get("tray_sub_brands", "")
         if not tray_sub_brands or tray_sub_brands.strip() == "":

+ 22 - 0
backend/tests/integration/test_settings_api.py

@@ -215,6 +215,28 @@ class TestSettingsAPI:
         assert result["currency"] == "JPY"
         assert result["check_updates"] is False
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_check_printer_firmware(self, async_client: AsyncClient):
+        """Verify check_printer_firmware can be updated."""
+        # Default should be True
+        response = await async_client.get("/api/v1/settings/")
+        assert response.json()["check_printer_firmware"] is True
+
+        # Update to False
+        response = await async_client.put("/api/v1/settings/", json={"check_printer_firmware": False})
+        assert response.status_code == 200
+        assert response.json()["check_printer_firmware"] is False
+
+        # Verify persistence
+        response = await async_client.get("/api/v1/settings/")
+        assert response.json()["check_printer_firmware"] is False
+
+        # Update back to True
+        response = await async_client.put("/api/v1/settings/", json={"check_printer_firmware": True})
+        assert response.status_code == 200
+        assert response.json()["check_printer_firmware"] is True
+
     # ========================================================================
     # MQTT settings tests
     # ========================================================================

+ 68 - 1
backend/tests/unit/services/test_printer_manager.py

@@ -12,6 +12,7 @@ import pytest
 from backend.app.services.printer_manager import (
     PrinterManager,
     get_derived_status_name,
+    has_stg_cur_idle_bug,
     init_printer_connections,
     printer_state_to_dict,
     supports_chamber_temp,
@@ -901,7 +902,7 @@ class TestGetDerivedStatusName:
         assert result == "Auto bed leveling"
 
     def test_stg_cur_zero_returns_printing(self):
-        """Verify stg_cur=0 returns 'Printing'."""
+        """Verify stg_cur=0 returns 'Printing' when no model specified."""
         state = MagicMock()
         state.stg_cur = 0
 
@@ -909,6 +910,72 @@ class TestGetDerivedStatusName:
 
         assert result == "Printing"
 
+    def test_a1_idle_with_stg_cur_zero_returns_none(self):
+        """Verify A1 with IDLE state and stg_cur=0 returns None (bug workaround)."""
+        state = MagicMock()
+        state.stg_cur = 0
+        state.state = "IDLE"
+
+        # Test various A1 model names
+        for model in ["A1", "A1 Mini", "A1-Mini", "A1MINI", "N1", "N2S"]:
+            result = get_derived_status_name(state, model)
+            assert result is None, f"Expected None for model {model}"
+
+    def test_a1_running_with_stg_cur_zero_returns_printing(self):
+        """Verify A1 with RUNNING state and stg_cur=0 still returns 'Printing'."""
+        state = MagicMock()
+        state.stg_cur = 0
+        state.state = "RUNNING"
+
+        result = get_derived_status_name(state, "A1")
+
+        assert result == "Printing"
+
+    def test_non_a1_idle_with_stg_cur_zero_returns_printing(self):
+        """Verify non-A1 models with IDLE and stg_cur=0 still return 'Printing'."""
+        state = MagicMock()
+        state.stg_cur = 0
+        state.state = "IDLE"
+
+        # X1C should not get the workaround
+        result = get_derived_status_name(state, "X1C")
+
+        assert result == "Printing"
+
+
+class TestHasStgCurIdleBug:
+    """Tests for has_stg_cur_idle_bug function."""
+
+    def test_a1_models_return_true(self):
+        """Verify A1 model variants return True."""
+        assert has_stg_cur_idle_bug("A1") is True
+        assert has_stg_cur_idle_bug("A1 Mini") is True
+        assert has_stg_cur_idle_bug("A1-Mini") is True
+        assert has_stg_cur_idle_bug("A1MINI") is True
+        assert has_stg_cur_idle_bug("a1") is True  # case insensitive
+        assert has_stg_cur_idle_bug("a1 mini") is True
+
+    def test_a1_internal_codes_return_true(self):
+        """Verify A1 internal model codes return True."""
+        assert has_stg_cur_idle_bug("N1") is True  # A1 Mini
+        assert has_stg_cur_idle_bug("N2S") is True  # A1
+
+    def test_non_a1_models_return_false(self):
+        """Verify non-A1 models return False."""
+        assert has_stg_cur_idle_bug("X1C") is False
+        assert has_stg_cur_idle_bug("X1") is False
+        assert has_stg_cur_idle_bug("P1P") is False
+        assert has_stg_cur_idle_bug("P1S") is False
+        assert has_stg_cur_idle_bug("H2D") is False
+
+    def test_none_model_returns_false(self):
+        """Verify None model returns False."""
+        assert has_stg_cur_idle_bug(None) is False
+
+    def test_empty_model_returns_false(self):
+        """Verify empty model returns False."""
+        assert has_stg_cur_idle_bug("") is False
+
 
 class TestInitPrinterConnections:
     """Tests for init_printer_connections function."""

+ 0 - 83
bambuddy-issue-notes.txt

@@ -1,83 +0,0 @@
-=== BAMBUDDY FILE DELETION ISSUE - Jan 8, 2026 ===
-=== ROOT CAUSE IDENTIFIED ===
-
-WHAT HAPPENED:
-- /opt was COMPLETELY DELETED on TWO containers:
-  - Container 109 (claude): ~11:22 and ~12:22
-  - Container 107 (3dp): ~13:28
-- Container 107 was "untouched" (no SSH, no Claude Code) - just running BamBuddy
-
-ROOT CAUSE FOUND:
-Bug in backend/app/services/archive.py delete_archive() function (lines 914-929):
-
-    file_path = settings.base_dir / archive.file_path
-    if file_path.exists():
-        archive_dir = file_path.parent
-        shutil.rmtree(archive_dir, ignore_errors=True)  # <-- THE BUG
-
-If archive.file_path is EMPTY or MALFORMED:
-- file_path = /opt/bambuddy / "" = /opt/bambuddy
-- archive_dir = file_path.parent = /opt
-- shutil.rmtree("/opt") --> DELETES ENTIRE /opt DIRECTORY!
-
-TRIGGER:
-- User was deleting archives via BamBuddy web UI on container 107 (3dp)
-- One archive had corrupted/empty file_path in database
-- Deleting that archive triggered shutil.rmtree("/opt")
-- This deleted the entire /opt directory including BamBuddy itself
-
-TIMELINE FOR CONTAINER 107 (3dp):
-- 13:28:19 - Normal operation (WebSocket disconnect)
-- 13:28:44 - DELETE /api/v1/archives/* requests failing with 500
-            (database already gone because /opt was deleted)
-- ls -la / shows root directory modified at 13:28
-
-FIX APPLIED (on container 109):
-Safety checks added to delete_archive() in archive.py:
-1. Check if file_path is not empty
-2. Verify archive_dir is inside settings.archive_dir
-3. Ensure archive_dir is at least 2 levels deep
-4. Log error and refuse to delete if checks fail
-
-TO INVESTIGATE AFTER ROLLBACK:
-On container 107, after rolling back to autodaily260108003006:
-
-    # Find corrupted archive records
-    sqlite3 /opt/bambuddy/data/bambuddy.db \
-      "SELECT id, filename, file_path FROM print_archives
-       WHERE file_path = '' OR file_path IS NULL
-       OR file_path NOT LIKE 'archive/%';"
-
-    # Check all file_path values
-    sqlite3 /opt/bambuddy/data/bambuddy.db \
-      "SELECT id, file_path FROM print_archives ORDER BY id;"
-
-CONTAINER 109 (this host):
-- Were you also deleting archives around 11:22 and 12:22?
-- Same bug could have been triggered here too
-
-PROXMOX COMMANDS FOR ROLLBACK:
-    # Container 107 (3dp)
-    pct rollback 107 autodaily260108003006
-    pct start 107
-
-    # Container 109 (claude) - already done via UI
-    # Current snapshot: autodaily260108003004
-
-WHAT TO DO NEXT:
-1. Rollback container 107 to morning snapshot
-2. Run the SQL query above to find corrupted archive
-3. Apply the fix from container 109 to container 107
-4. Understand how the file_path got corrupted in the first place
-
-THE FIX (apply to both containers):
-In backend/app/services/archive.py, the delete_archive function now has:
-- Empty file_path check
-- Path traversal protection (relative_to check)
-- Minimum depth check (must be 2+ levels inside archive dir)
-- Error logging for refused deletions
-
-NOT CLAUDE CODE'S FAULT:
-This was a bug in BamBuddy's own code that was triggered by:
-1. Corrupted database record (unknown how it got corrupted)
-2. User action (deleting archives via web UI)

+ 7 - 7
deploy/bambuddy.service

@@ -6,8 +6,8 @@ After=network.target
 Type=simple
 User=claude
 Group=claude
-WorkingDirectory=/opt/claude/projects/bambuddy
-Environment="PATH=/opt/claude/projects/bambuddy/venv/bin"
+WorkingDirectory=<dir>/bambuddy
+Environment="PATH=<dir/bambuddy/venv/bin"
 
 # Force kill after 10 seconds if graceful shutdown fails
 TimeoutStopSec=10
@@ -18,12 +18,12 @@ ExecStopPost=-/usr/bin/pkill -9 ffmpeg
 
 # Ensure directories exist and have correct permissions before starting
 # The + prefix runs the command as root even though User=claude
-ExecStartPre=+/bin/mkdir -p /opt/claude/projects/bambuddy/logs
-ExecStartPre=+/bin/mkdir -p /opt/claude/projects/bambuddy/archive
-ExecStartPre=+/bin/chown -R claude:claude /opt/claude/projects/bambuddy/logs
-ExecStartPre=+/bin/chown -R claude:claude /opt/claude/projects/bambuddy/archive
+ExecStartPre=+/bin/mkdir -p <dir>/bambuddy/logs
+ExecStartPre=+/bin/mkdir -p <dir>/bambuddy/archive
+ExecStartPre=+/bin/chown -R <user>:<user> <dir>/bambuddy/logs
+ExecStartPre=+/bin/chown -R <user>:<user> <dir>/bambuddy/archive
 
-ExecStart=/opt/claude/projects/bambuddy/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port 8000
+ExecStart=<dir>/bambuddy/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port 8000
 Restart=always
 RestartSec=10
 

+ 1 - 1
frontend/eslint.config.js

@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
 import { defineConfig, globalIgnores } from 'eslint/config'
 
 export default defineConfig([
-  globalIgnores(['dist']),
+  globalIgnores(['dist', 'coverage']),
   {
     files: ['**/*.{ts,tsx}'],
     extends: [

+ 192 - 1058
frontend/package-lock.json

@@ -44,7 +44,7 @@
         "@types/react": "^19.2.5",
         "@types/react-dom": "^19.2.3",
         "@vitejs/plugin-react": "^5.1.1",
-        "@vitest/coverage-v8": "^2.1.0",
+        "@vitest/coverage-v8": "^3.2.4",
         "autoprefixer": "^10.4.22",
         "baseline-browser-mapping": "^2.9.18",
         "eslint": "^9.39.1",
@@ -58,7 +58,7 @@
         "typescript": "~5.9.3",
         "typescript-eslint": "^8.46.4",
         "vite": "^7.2.4",
-        "vitest": "^2.1.0"
+        "vitest": "^3.2.4"
       }
     },
     "node_modules/@adobe/css-tools": {
@@ -404,10 +404,13 @@
       }
     },
     "node_modules/@bcoe/v8-coverage": {
-      "version": "0.2.3",
-      "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
-      "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
-      "dev": true
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+      "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+      "dev": true,
+      "engines": {
+        "node": ">=18"
+      }
     },
     "node_modules/@csstools/color-helpers": {
       "version": "5.1.0",
@@ -2848,6 +2851,16 @@
         "@babel/types": "^7.28.2"
       }
     },
+    "node_modules/@types/chai": {
+      "version": "5.2.3",
+      "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+      "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+      "dev": true,
+      "dependencies": {
+        "@types/deep-eql": "*",
+        "assertion-error": "^2.0.1"
+      }
+    },
     "node_modules/@types/d3-array": {
       "version": "3.2.2",
       "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
@@ -2911,6 +2924,12 @@
       "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
       "license": "MIT"
     },
+    "node_modules/@types/deep-eql": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+      "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+      "dev": true
+    },
     "node_modules/@types/estree": {
       "version": "1.0.8",
       "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -3306,30 +3325,31 @@
       }
     },
     "node_modules/@vitest/coverage-v8": {
-      "version": "2.1.9",
-      "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz",
-      "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==",
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz",
+      "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==",
       "dev": true,
       "dependencies": {
         "@ampproject/remapping": "^2.3.0",
-        "@bcoe/v8-coverage": "^0.2.3",
-        "debug": "^4.3.7",
+        "@bcoe/v8-coverage": "^1.0.2",
+        "ast-v8-to-istanbul": "^0.3.3",
+        "debug": "^4.4.1",
         "istanbul-lib-coverage": "^3.2.2",
         "istanbul-lib-report": "^3.0.1",
         "istanbul-lib-source-maps": "^5.0.6",
         "istanbul-reports": "^3.1.7",
-        "magic-string": "^0.30.12",
+        "magic-string": "^0.30.17",
         "magicast": "^0.3.5",
-        "std-env": "^3.8.0",
+        "std-env": "^3.9.0",
         "test-exclude": "^7.0.1",
-        "tinyrainbow": "^1.2.0"
+        "tinyrainbow": "^2.0.0"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
       },
       "peerDependencies": {
-        "@vitest/browser": "2.1.9",
-        "vitest": "2.1.9"
+        "@vitest/browser": "3.2.4",
+        "vitest": "3.2.4"
       },
       "peerDependenciesMeta": {
         "@vitest/browser": {
@@ -3338,80 +3358,108 @@
       }
     },
     "node_modules/@vitest/expect": {
-      "version": "2.1.9",
-      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
-      "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
+      "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
+      "dev": true,
+      "dependencies": {
+        "@types/chai": "^5.2.2",
+        "@vitest/spy": "3.2.4",
+        "@vitest/utils": "3.2.4",
+        "chai": "^5.2.0",
+        "tinyrainbow": "^2.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/mocker": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
+      "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
       "dev": true,
       "dependencies": {
-        "@vitest/spy": "2.1.9",
-        "@vitest/utils": "2.1.9",
-        "chai": "^5.1.2",
-        "tinyrainbow": "^1.2.0"
+        "@vitest/spy": "3.2.4",
+        "estree-walker": "^3.0.3",
+        "magic-string": "^0.30.17"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "msw": "^2.4.9",
+        "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+      },
+      "peerDependenciesMeta": {
+        "msw": {
+          "optional": true
+        },
+        "vite": {
+          "optional": true
+        }
       }
     },
     "node_modules/@vitest/pretty-format": {
-      "version": "2.1.9",
-      "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
-      "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
+      "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
       "dev": true,
       "dependencies": {
-        "tinyrainbow": "^1.2.0"
+        "tinyrainbow": "^2.0.0"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
       }
     },
     "node_modules/@vitest/runner": {
-      "version": "2.1.9",
-      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz",
-      "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
+      "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
       "dev": true,
       "dependencies": {
-        "@vitest/utils": "2.1.9",
-        "pathe": "^1.1.2"
+        "@vitest/utils": "3.2.4",
+        "pathe": "^2.0.3",
+        "strip-literal": "^3.0.0"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
       }
     },
     "node_modules/@vitest/snapshot": {
-      "version": "2.1.9",
-      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz",
-      "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
+      "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
       "dev": true,
       "dependencies": {
-        "@vitest/pretty-format": "2.1.9",
-        "magic-string": "^0.30.12",
-        "pathe": "^1.1.2"
+        "@vitest/pretty-format": "3.2.4",
+        "magic-string": "^0.30.17",
+        "pathe": "^2.0.3"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
       }
     },
     "node_modules/@vitest/spy": {
-      "version": "2.1.9",
-      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz",
-      "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
+      "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
       "dev": true,
       "dependencies": {
-        "tinyspy": "^3.0.2"
+        "tinyspy": "^4.0.3"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
       }
     },
     "node_modules/@vitest/utils": {
-      "version": "2.1.9",
-      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
-      "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
+      "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
       "dev": true,
       "dependencies": {
-        "@vitest/pretty-format": "2.1.9",
-        "loupe": "^3.1.2",
-        "tinyrainbow": "^1.2.0"
+        "@vitest/pretty-format": "3.2.4",
+        "loupe": "^3.1.4",
+        "tinyrainbow": "^2.0.0"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
@@ -3521,6 +3569,23 @@
         "node": ">=12"
       }
     },
+    "node_modules/ast-v8-to-istanbul": {
+      "version": "0.3.10",
+      "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz",
+      "integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==",
+      "dev": true,
+      "dependencies": {
+        "@jridgewell/trace-mapping": "^0.3.31",
+        "estree-walker": "^3.0.3",
+        "js-tokens": "^9.0.1"
+      }
+    },
+    "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+      "version": "9.0.1",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+      "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+      "dev": true
+    },
     "node_modules/asynckit": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -3713,9 +3778,9 @@
       }
     },
     "node_modules/check-error": {
-      "version": "2.1.1",
-      "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
-      "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==",
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+      "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
       "dev": true,
       "engines": {
         "node": ">= 16"
@@ -6288,9 +6353,9 @@
       "dev": true
     },
     "node_modules/pathe": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
-      "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+      "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
       "dev": true
     },
     "node_modules/pathval": {
@@ -7161,6 +7226,24 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/strip-literal": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
+      "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
+      "dev": true,
+      "dependencies": {
+        "js-tokens": "^9.0.1"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/strip-literal/node_modules/js-tokens": {
+      "version": "9.0.1",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+      "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+      "dev": true
+    },
     "node_modules/supports-color": {
       "version": "7.2.0",
       "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -7302,18 +7385,18 @@
       }
     },
     "node_modules/tinyrainbow": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
-      "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+      "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
       "dev": true,
       "engines": {
         "node": ">=14.0.0"
       }
     },
     "node_modules/tinyspy": {
-      "version": "3.0.2",
-      "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
-      "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
+      "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
       "dev": true,
       "engines": {
         "node": ">=14.0.0"
@@ -7616,533 +7699,72 @@
       }
     },
     "node_modules/vite-node": {
-      "version": "2.1.9",
-      "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
-      "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+      "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
       "dev": true,
       "dependencies": {
         "cac": "^6.7.14",
-        "debug": "^4.3.7",
-        "es-module-lexer": "^1.5.4",
-        "pathe": "^1.1.2",
-        "vite": "^5.0.0"
+        "debug": "^4.4.1",
+        "es-module-lexer": "^1.7.0",
+        "pathe": "^2.0.3",
+        "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
       },
       "bin": {
         "vite-node": "vite-node.mjs"
       },
       "engines": {
-        "node": "^18.0.0 || >=20.0.0"
+        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
       }
     },
-    "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
-      "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
-      "cpu": [
-        "ppc64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "aix"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/android-arm": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
-      "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
-      "cpu": [
-        "arm"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/android-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
-      "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/android-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
-      "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
-      "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/darwin-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
-      "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
-      "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
-      "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/linux-arm": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
-      "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
-      "cpu": [
-        "arm"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/linux-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
-      "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/linux-ia32": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
-      "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
-      "cpu": [
-        "ia32"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/linux-loong64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
-      "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
-      "cpu": [
-        "loong64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
-      "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
-      "cpu": [
-        "mips64el"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
-      "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
-      "cpu": [
-        "ppc64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
-      "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
-      "cpu": [
-        "riscv64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
+    "node_modules/vitest": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
+      "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
+      "dev": true,
+      "dependencies": {
+        "@types/chai": "^5.2.2",
+        "@vitest/expect": "3.2.4",
+        "@vitest/mocker": "3.2.4",
+        "@vitest/pretty-format": "^3.2.4",
+        "@vitest/runner": "3.2.4",
+        "@vitest/snapshot": "3.2.4",
+        "@vitest/spy": "3.2.4",
+        "@vitest/utils": "3.2.4",
+        "chai": "^5.2.0",
+        "debug": "^4.4.1",
+        "expect-type": "^1.2.1",
+        "magic-string": "^0.30.17",
+        "pathe": "^2.0.3",
+        "picomatch": "^4.0.2",
+        "std-env": "^3.9.0",
+        "tinybench": "^2.9.0",
+        "tinyexec": "^0.3.2",
+        "tinyglobby": "^0.2.14",
+        "tinypool": "^1.1.1",
+        "tinyrainbow": "^2.0.0",
+        "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+        "vite-node": "3.2.4",
+        "why-is-node-running": "^2.3.0"
+      },
+      "bin": {
+        "vitest": "vitest.mjs"
+      },
       "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/linux-s390x": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
-      "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
-      "cpu": [
-        "s390x"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/linux-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
-      "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
-      "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "netbsd"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
-      "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "openbsd"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/sunos-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
-      "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "sunos"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/win32-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
-      "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/win32-ia32": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
-      "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
-      "cpu": [
-        "ia32"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/@esbuild/win32-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
-      "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite-node/node_modules/esbuild": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
-      "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
-      "dev": true,
-      "hasInstallScript": true,
-      "bin": {
-        "esbuild": "bin/esbuild"
-      },
-      "engines": {
-        "node": ">=12"
-      },
-      "optionalDependencies": {
-        "@esbuild/aix-ppc64": "0.21.5",
-        "@esbuild/android-arm": "0.21.5",
-        "@esbuild/android-arm64": "0.21.5",
-        "@esbuild/android-x64": "0.21.5",
-        "@esbuild/darwin-arm64": "0.21.5",
-        "@esbuild/darwin-x64": "0.21.5",
-        "@esbuild/freebsd-arm64": "0.21.5",
-        "@esbuild/freebsd-x64": "0.21.5",
-        "@esbuild/linux-arm": "0.21.5",
-        "@esbuild/linux-arm64": "0.21.5",
-        "@esbuild/linux-ia32": "0.21.5",
-        "@esbuild/linux-loong64": "0.21.5",
-        "@esbuild/linux-mips64el": "0.21.5",
-        "@esbuild/linux-ppc64": "0.21.5",
-        "@esbuild/linux-riscv64": "0.21.5",
-        "@esbuild/linux-s390x": "0.21.5",
-        "@esbuild/linux-x64": "0.21.5",
-        "@esbuild/netbsd-x64": "0.21.5",
-        "@esbuild/openbsd-x64": "0.21.5",
-        "@esbuild/sunos-x64": "0.21.5",
-        "@esbuild/win32-arm64": "0.21.5",
-        "@esbuild/win32-ia32": "0.21.5",
-        "@esbuild/win32-x64": "0.21.5"
-      }
-    },
-    "node_modules/vite-node/node_modules/vite": {
-      "version": "5.4.21",
-      "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
-      "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
-      "dev": true,
-      "dependencies": {
-        "esbuild": "^0.21.3",
-        "postcss": "^8.4.43",
-        "rollup": "^4.20.0"
-      },
-      "bin": {
-        "vite": "bin/vite.js"
-      },
-      "engines": {
-        "node": "^18.0.0 || >=20.0.0"
-      },
-      "funding": {
-        "url": "https://github.com/vitejs/vite?sponsor=1"
-      },
-      "optionalDependencies": {
-        "fsevents": "~2.3.3"
-      },
-      "peerDependencies": {
-        "@types/node": "^18.0.0 || >=20.0.0",
-        "less": "*",
-        "lightningcss": "^1.21.0",
-        "sass": "*",
-        "sass-embedded": "*",
-        "stylus": "*",
-        "sugarss": "*",
-        "terser": "^5.4.0"
-      },
-      "peerDependenciesMeta": {
-        "@types/node": {
-          "optional": true
-        },
-        "less": {
-          "optional": true
-        },
-        "lightningcss": {
-          "optional": true
-        },
-        "sass": {
-          "optional": true
-        },
-        "sass-embedded": {
-          "optional": true
-        },
-        "stylus": {
-          "optional": true
-        },
-        "sugarss": {
-          "optional": true
-        },
-        "terser": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/vitest": {
-      "version": "2.1.9",
-      "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
-      "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
-      "dev": true,
-      "dependencies": {
-        "@vitest/expect": "2.1.9",
-        "@vitest/mocker": "2.1.9",
-        "@vitest/pretty-format": "^2.1.9",
-        "@vitest/runner": "2.1.9",
-        "@vitest/snapshot": "2.1.9",
-        "@vitest/spy": "2.1.9",
-        "@vitest/utils": "2.1.9",
-        "chai": "^5.1.2",
-        "debug": "^4.3.7",
-        "expect-type": "^1.1.0",
-        "magic-string": "^0.30.12",
-        "pathe": "^1.1.2",
-        "std-env": "^3.8.0",
-        "tinybench": "^2.9.0",
-        "tinyexec": "^0.3.1",
-        "tinypool": "^1.0.1",
-        "tinyrainbow": "^1.2.0",
-        "vite": "^5.0.0",
-        "vite-node": "2.1.9",
-        "why-is-node-running": "^2.3.0"
-      },
-      "bin": {
-        "vitest": "vitest.mjs"
-      },
-      "engines": {
-        "node": "^18.0.0 || >=20.0.0"
+        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
       },
       "peerDependencies": {
         "@edge-runtime/vm": "*",
-        "@types/node": "^18.0.0 || >=20.0.0",
-        "@vitest/browser": "2.1.9",
-        "@vitest/ui": "2.1.9",
+        "@types/debug": "^4.1.12",
+        "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+        "@vitest/browser": "3.2.4",
+        "@vitest/ui": "3.2.4",
         "happy-dom": "*",
         "jsdom": "*"
       },
@@ -8150,6 +7772,9 @@
         "@edge-runtime/vm": {
           "optional": true
         },
+        "@types/debug": {
+          "optional": true
+        },
         "@types/node": {
           "optional": true
         },
@@ -8167,497 +7792,6 @@
         }
       }
     },
-    "node_modules/vitest/node_modules/@esbuild/aix-ppc64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
-      "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
-      "cpu": [
-        "ppc64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "aix"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/android-arm": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
-      "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
-      "cpu": [
-        "arm"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/android-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
-      "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/android-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
-      "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/darwin-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
-      "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/darwin-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
-      "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
-      "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/freebsd-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
-      "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/linux-arm": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
-      "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
-      "cpu": [
-        "arm"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/linux-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
-      "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/linux-ia32": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
-      "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
-      "cpu": [
-        "ia32"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/linux-loong64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
-      "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
-      "cpu": [
-        "loong64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/linux-mips64el": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
-      "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
-      "cpu": [
-        "mips64el"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/linux-ppc64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
-      "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
-      "cpu": [
-        "ppc64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/linux-riscv64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
-      "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
-      "cpu": [
-        "riscv64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/linux-s390x": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
-      "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
-      "cpu": [
-        "s390x"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/linux-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
-      "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/netbsd-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
-      "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "netbsd"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/openbsd-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
-      "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "openbsd"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/sunos-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
-      "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "sunos"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/win32-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
-      "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/win32-ia32": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
-      "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
-      "cpu": [
-        "ia32"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@esbuild/win32-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
-      "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vitest/node_modules/@vitest/mocker": {
-      "version": "2.1.9",
-      "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz",
-      "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
-      "dev": true,
-      "dependencies": {
-        "@vitest/spy": "2.1.9",
-        "estree-walker": "^3.0.3",
-        "magic-string": "^0.30.12"
-      },
-      "funding": {
-        "url": "https://opencollective.com/vitest"
-      },
-      "peerDependencies": {
-        "msw": "^2.4.9",
-        "vite": "^5.0.0"
-      },
-      "peerDependenciesMeta": {
-        "msw": {
-          "optional": true
-        },
-        "vite": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/vitest/node_modules/esbuild": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
-      "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
-      "dev": true,
-      "hasInstallScript": true,
-      "bin": {
-        "esbuild": "bin/esbuild"
-      },
-      "engines": {
-        "node": ">=12"
-      },
-      "optionalDependencies": {
-        "@esbuild/aix-ppc64": "0.21.5",
-        "@esbuild/android-arm": "0.21.5",
-        "@esbuild/android-arm64": "0.21.5",
-        "@esbuild/android-x64": "0.21.5",
-        "@esbuild/darwin-arm64": "0.21.5",
-        "@esbuild/darwin-x64": "0.21.5",
-        "@esbuild/freebsd-arm64": "0.21.5",
-        "@esbuild/freebsd-x64": "0.21.5",
-        "@esbuild/linux-arm": "0.21.5",
-        "@esbuild/linux-arm64": "0.21.5",
-        "@esbuild/linux-ia32": "0.21.5",
-        "@esbuild/linux-loong64": "0.21.5",
-        "@esbuild/linux-mips64el": "0.21.5",
-        "@esbuild/linux-ppc64": "0.21.5",
-        "@esbuild/linux-riscv64": "0.21.5",
-        "@esbuild/linux-s390x": "0.21.5",
-        "@esbuild/linux-x64": "0.21.5",
-        "@esbuild/netbsd-x64": "0.21.5",
-        "@esbuild/openbsd-x64": "0.21.5",
-        "@esbuild/sunos-x64": "0.21.5",
-        "@esbuild/win32-arm64": "0.21.5",
-        "@esbuild/win32-ia32": "0.21.5",
-        "@esbuild/win32-x64": "0.21.5"
-      }
-    },
-    "node_modules/vitest/node_modules/vite": {
-      "version": "5.4.21",
-      "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
-      "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
-      "dev": true,
-      "dependencies": {
-        "esbuild": "^0.21.3",
-        "postcss": "^8.4.43",
-        "rollup": "^4.20.0"
-      },
-      "bin": {
-        "vite": "bin/vite.js"
-      },
-      "engines": {
-        "node": "^18.0.0 || >=20.0.0"
-      },
-      "funding": {
-        "url": "https://github.com/vitejs/vite?sponsor=1"
-      },
-      "optionalDependencies": {
-        "fsevents": "~2.3.3"
-      },
-      "peerDependencies": {
-        "@types/node": "^18.0.0 || >=20.0.0",
-        "less": "*",
-        "lightningcss": "^1.21.0",
-        "sass": "*",
-        "sass-embedded": "*",
-        "stylus": "*",
-        "sugarss": "*",
-        "terser": "^5.4.0"
-      },
-      "peerDependenciesMeta": {
-        "@types/node": {
-          "optional": true
-        },
-        "less": {
-          "optional": true
-        },
-        "lightningcss": {
-          "optional": true
-        },
-        "sass": {
-          "optional": true
-        },
-        "sass-embedded": {
-          "optional": true
-        },
-        "stylus": {
-          "optional": true
-        },
-        "sugarss": {
-          "optional": true
-        },
-        "terser": {
-          "optional": true
-        }
-      }
-    },
     "node_modules/void-elements": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",

+ 2 - 2
frontend/package.json

@@ -50,7 +50,7 @@
     "@types/react": "^19.2.5",
     "@types/react-dom": "^19.2.3",
     "@vitejs/plugin-react": "^5.1.1",
-    "@vitest/coverage-v8": "^2.1.0",
+    "@vitest/coverage-v8": "^3.2.4",
     "autoprefixer": "^10.4.22",
     "baseline-browser-mapping": "^2.9.18",
     "eslint": "^9.39.1",
@@ -64,6 +64,6 @@
     "typescript": "~5.9.3",
     "typescript-eslint": "^8.46.4",
     "vite": "^7.2.4",
-    "vitest": "^2.1.0"
+    "vitest": "^3.2.4"
   }
 }

+ 1 - 1
frontend/src/__tests__/api/githubBackupApi.test.ts

@@ -2,7 +2,7 @@
  * Tests for the GitHub Backup API client functions.
  */
 
-import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { describe, it, expect } from 'vitest';
 import { http, HttpResponse } from 'msw';
 import { setupServer } from 'msw/node';
 import type {

+ 1 - 0
frontend/src/__tests__/components/Layout.test.tsx

@@ -29,6 +29,7 @@ describe('Layout', () => {
       http.get('/api/v1/settings/', () => {
         return HttpResponse.json({
           check_updates: false,
+          check_printer_firmware: false,
           auto_archive: true,
         });
       }),

+ 40 - 0
frontend/src/__tests__/pages/ArchivesPage.test.tsx

@@ -261,4 +261,44 @@ describe('ArchivesPage', () => {
       });
     });
   });
+
+  describe('plate navigation', () => {
+    it('renders archive cards with thumbnails', async () => {
+      render(<ArchivesPage />);
+
+      await waitFor(() => {
+        // Archive cards should render with their thumbnails
+        expect(screen.getByText('Benchy')).toBeInTheDocument();
+        // Thumbnail images should be present (archive cards have img elements)
+        const images = document.querySelectorAll('img[alt="Benchy"]');
+        expect(images.length).toBeGreaterThanOrEqual(0);
+      });
+    });
+
+    it('fetches plate data for multi-plate archives on hover', async () => {
+      // Setup handler for plates endpoint
+      server.use(
+        http.get('/api/v1/archives/:id/plates', ({ params }) => {
+          return HttpResponse.json({
+            archive_id: Number(params.id),
+            filename: 'test.3mf',
+            plates: [
+              { index: 0, name: 'Plate 1', objects: ['Object A'], has_thumbnail: true, thumbnail_url: '/thumb1.png', print_time_seconds: 3600, filament_used_grams: 10, filaments: [] },
+              { index: 1, name: 'Plate 2', objects: ['Object B'], has_thumbnail: true, thumbnail_url: '/thumb2.png', print_time_seconds: 1800, filament_used_grams: 5, filaments: [] },
+            ],
+            is_multi_plate: true,
+          });
+        })
+      );
+
+      render(<ArchivesPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Benchy')).toBeInTheDocument();
+      });
+
+      // Archives with multi-plate support will show navigation on hover
+      // The plates API is called lazily when hovering
+    });
+  });
 });

+ 1 - 0
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -105,6 +105,7 @@ describe('FileManagerPage', () => {
       http.get('/api/v1/settings/', () => {
         return HttpResponse.json({
           check_updates: false,
+          check_printer_firmware: false,
           library_disk_warning_gb: 5,
         });
       }),

+ 21 - 0
frontend/src/__tests__/pages/QueuePage.test.tsx

@@ -215,6 +215,27 @@ describe('QueuePage', () => {
         expect(printerElements.length).toBeGreaterThan(0);
       });
     });
+
+    it('renders queue items with plate_id correctly', async () => {
+      // Override with queue items that have plate_id set
+      server.use(
+        http.get('/api/v1/queue/', () => {
+          return HttpResponse.json([
+            {
+              ...mockQueueItems[0],
+              plate_id: 2,
+              archive_name: 'Multi-plate Print',
+            },
+          ]);
+        })
+      );
+
+      render(<QueuePage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Multi-plate Print')).toBeInTheDocument();
+      });
+    });
   });
 
   describe('empty state', () => {

+ 11 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -31,6 +31,7 @@ const mockSettings = {
   ha_url: '',
   ha_token: '',
   check_updates: false,
+  check_printer_firmware: false,
 };
 
 describe('SettingsPage', () => {
@@ -124,6 +125,16 @@ describe('SettingsPage', () => {
         expect(screen.getByText('Appearance')).toBeInTheDocument();
       });
     });
+
+    it('shows updates section with firmware toggle', async () => {
+      render(<SettingsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Updates')).toBeInTheDocument();
+        expect(screen.getByText('Check for updates')).toBeInTheDocument();
+        expect(screen.getByText('Check printer firmware')).toBeInTheDocument();
+      });
+    });
   });
 
   describe('tabs navigation', () => {

+ 1 - 0
frontend/src/__tests__/pages/StatsPage.test.tsx

@@ -48,6 +48,7 @@ const mockArchives = [
 const mockSettings = {
   currency: '$',
   check_updates: false,
+  check_printer_firmware: false,
 };
 
 const mockFailureAnalysis = {

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

@@ -682,6 +682,7 @@ export interface AppSettings {
   energy_cost_per_kwh: number;
   energy_tracking_mode: 'print' | 'total';
   check_updates: boolean;
+  check_printer_firmware: boolean;
   notification_language: string;
   // AMS threshold settings
   ams_humidity_good: number;  // <= this is green
@@ -2014,6 +2015,8 @@ export const api = {
       method: 'POST',
     }),
   getArchiveThumbnail: (id: number) => `${API_BASE}/archives/${id}/thumbnail?v=${Date.now()}`,
+  getArchivePlateThumbnail: (id: number, plateIndex: number) =>
+    `${API_BASE}/archives/${id}/plate-thumbnail/${plateIndex}`,
   getArchiveDownload: (id: number) => `${API_BASE}/archives/${id}/download`,
   getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`,
   getArchivePlatePreview: (id: number) => `${API_BASE}/archives/${id}/plate-preview`,
@@ -3071,6 +3074,8 @@ export const api = {
     request<{ status: string; message: string }>(`/library/files/${id}`, { method: 'DELETE' }),
   getLibraryFileDownloadUrl: (id: number) => `${API_BASE}/library/files/${id}/download`,
   getLibraryFileThumbnailUrl: (id: number) => `${API_BASE}/library/files/${id}/thumbnail`,
+  getLibraryFilePlateThumbnail: (id: number, plateIndex: number) =>
+    `${API_BASE}/library/files/${id}/plate-thumbnail/${plateIndex}`,
   getLibraryFileGcodeUrl: (id: number) => `${API_BASE}/library/files/${id}/gcode`,
   moveLibraryFiles: (fileIds: number[], folderId: number | null) =>
     request<{ status: string; moved: number }>('/library/files/move', {

+ 15 - 7
frontend/src/components/GitHubBackupSettings.tsx

@@ -1,5 +1,5 @@
 import { useState, useEffect, useRef, useCallback } from 'react';
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
 import {
   Github,
   Play,
@@ -27,7 +27,6 @@ import type {
   ScheduleType,
   CloudAuthStatus,
   Printer,
-  PrinterStatus,
 } from '../api/client';
 import { Card, CardContent, CardHeader } from './Card';
 import { Button } from './Button';
@@ -149,11 +148,20 @@ export function GitHubBackupSettings() {
     queryFn: api.getPrinters,
   });
 
-  // Get printer statuses from cache (populated by WebSocket on other pages)
-  const printerStatuses = printers?.map(p => {
-    const status = queryClient.getQueryData<PrinterStatus>(['printerStatus', p.id]);
-    return { printer: p, connected: status?.connected ?? false };
-  }) ?? [];
+  // Fetch printer statuses from API (not just cache) to get accurate connection status
+  const printerStatusQueries = useQueries({
+    queries: (printers ?? []).map(printer => ({
+      queryKey: ['printerStatus', printer.id],
+      queryFn: () => api.getPrinterStatus(printer.id),
+      staleTime: 10000, // Consider stale after 10s
+      refetchInterval: 30000, // Refresh every 30s
+    })),
+  });
+
+  const printerStatuses = (printers ?? []).map((printer, index) => ({
+    printer,
+    connected: printerStatusQueries[index]?.data?.connected ?? false,
+  }));
 
   const totalPrinters = printerStatuses.length;
   const connectedPrinters = printerStatuses.filter(p => p.connected).length;

+ 84 - 3
frontend/src/pages/ArchivesPage.tsx

@@ -41,6 +41,8 @@ import {
   GitCompare,
   Loader2,
   FolderKanban,
+  ChevronLeft,
+  ChevronRight,
 } from 'lucide-react';
 import { api } from '../api/client';
 import { openInSlicer } from '../utils/slicer';
@@ -133,9 +135,23 @@ function ArchiveCard({
   const [showDeleteSource3mfConfirm, setShowDeleteSource3mfConfirm] = useState(false);
   const [showDeleteF3dConfirm, setShowDeleteF3dConfirm] = useState(false);
   const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
+  const [currentPlateIndex, setCurrentPlateIndex] = useState<number | null>(null);
+  const [showPlateNav, setShowPlateNav] = useState(false);
   const source3mfInputRef = useRef<HTMLInputElement>(null);
   const f3dInputRef = useRef<HTMLInputElement>(null);
 
+  // Fetch plates data for multi-plate browsing (lazy - only when hovering)
+  const { data: platesData } = useQuery({
+    queryKey: ['archive-plates', archive.id],
+    queryFn: () => api.getArchivePlates(archive.id),
+    enabled: showPlateNav, // Only fetch when user hovers to see navigation
+    staleTime: 5 * 60 * 1000, // Cache for 5 minutes
+  });
+
+  const plates = platesData?.plates ?? [];
+  const isMultiPlate = platesData?.is_multi_plate ?? false;
+  const displayPlateIndex = currentPlateIndex ?? 0;
+
   const source3mfUploadMutation = useMutation({
     mutationFn: (file: File) => api.uploadSource3mf(archive.id, file),
     onSuccess: (data) => {
@@ -505,11 +521,19 @@ function ArchiveCard({
         </button>
       )}
 
-      {/* Thumbnail */}
-      <div className="aspect-video bg-bambu-dark relative flex-shrink-0 overflow-hidden rounded-t-xl">
+      {/* Thumbnail with plate navigation */}
+      <div
+        className="aspect-video bg-bambu-dark relative flex-shrink-0 overflow-hidden rounded-t-xl"
+        onMouseEnter={() => setShowPlateNav(true)}
+        onMouseLeave={() => setShowPlateNav(false)}
+      >
         {archive.thumbnail_path ? (
           <img
-            src={api.getArchiveThumbnail(archive.id)}
+            src={
+              currentPlateIndex !== null && plates.length > 0
+                ? api.getArchivePlateThumbnail(archive.id, plates[displayPlateIndex]?.index ?? 0)
+                : api.getArchiveThumbnail(archive.id)
+            }
             alt={archive.print_name || archive.filename}
             className="w-full h-full object-cover"
           />
@@ -518,6 +542,63 @@ function ArchiveCard({
             <Image className="w-12 h-12 text-bambu-dark-tertiary" />
           </div>
         )}
+        {/* Plate navigation - only show for multi-plate archives */}
+        {isMultiPlate && plates.length > 1 && (
+          <>
+            {/* Left arrow */}
+            <button
+              className={`absolute left-1 top-1/2 -translate-y-1/2 p-1 rounded-full bg-black/60 hover:bg-black/80 transition-all ${
+                isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
+              }`}
+              onClick={(e) => {
+                e.stopPropagation();
+                setCurrentPlateIndex((prev) => {
+                  const current = prev ?? 0;
+                  return current > 0 ? current - 1 : plates.length - 1;
+                });
+              }}
+              title="Previous plate"
+            >
+              <ChevronLeft className="w-4 h-4 text-white" />
+            </button>
+            {/* Right arrow */}
+            <button
+              className={`absolute right-1 top-1/2 -translate-y-1/2 p-1 rounded-full bg-black/60 hover:bg-black/80 transition-all ${
+                isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
+              }`}
+              onClick={(e) => {
+                e.stopPropagation();
+                setCurrentPlateIndex((prev) => {
+                  const current = prev ?? 0;
+                  return current < plates.length - 1 ? current + 1 : 0;
+                });
+              }}
+              title="Next plate"
+            >
+              <ChevronRight className="w-4 h-4 text-white" />
+            </button>
+            {/* Dots indicator */}
+            <div
+              className={`absolute bottom-1 left-1/2 -translate-x-1/2 flex gap-1 px-2 py-1 rounded-full bg-black/50 transition-all ${
+                isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
+              }`}
+            >
+              {plates.map((plate, idx) => (
+                <button
+                  key={plate.index}
+                  className={`w-2 h-2 rounded-full transition-colors ${
+                    idx === displayPlateIndex ? 'bg-bambu-green' : 'bg-white/50 hover:bg-white/80'
+                  }`}
+                  onClick={(e) => {
+                    e.stopPropagation();
+                    setCurrentPlateIndex(idx);
+                  }}
+                  title={plate.name || `Plate ${plate.index}`}
+                />
+              ))}
+            </div>
+          </>
+        )}
         {/* Context menu button - visible on mobile, shows on hover for desktop */}
         <button
           className={`absolute top-2 left-2 p-1.5 rounded bg-black/50 hover:bg-black/70 transition-all ${

+ 10 - 5
frontend/src/pages/PrintersPage.tsx

@@ -915,6 +915,7 @@ function PrinterCard({
   timeFormat = 'system',
   cameraViewMode = 'window',
   onOpenEmbeddedCamera,
+  checkPrinterFirmware = true,
 }: {
   printer: Printer;
   hideIfDisconnected?: boolean;
@@ -932,6 +933,7 @@ function PrinterCard({
   timeFormat?: 'system' | '12h' | '24h';
   cameraViewMode?: 'window' | 'embedded';
   onOpenEmbeddedCamera?: (printerId: number, printerName: string) => void;
+  checkPrinterFirmware?: boolean;
 }) {
   const queryClient = useQueryClient();
   const navigate = useNavigate();
@@ -992,12 +994,13 @@ function PrinterCard({
     refetchInterval: 30000, // Fallback polling, WebSocket handles real-time
   });
 
-  // Check for firmware updates (cached for 5 minutes)
+  // Check for firmware updates (cached for 5 minutes, can be disabled in settings)
   const { data: firmwareInfo } = useQuery({
     queryKey: ['firmwareUpdate', printer.id],
     queryFn: () => firmwareApi.checkPrinterUpdate(printer.id),
     staleTime: 5 * 60 * 1000,
     refetchInterval: 5 * 60 * 1000,
+    enabled: checkPrinterFirmware,
   });
 
   // Collect unique tray_info_idx values for cloud filament info lookup
@@ -2685,12 +2688,12 @@ function PrinterCard({
 
         {/* Connection Info & Actions - hidden in compact mode */}
         {viewMode === 'expanded' && (
-          <div className="mt-4 pt-4 border-t border-bambu-dark-tertiary flex items-center justify-between">
+          <div className="mt-4 pt-4 border-t border-bambu-dark-tertiary flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
             <div className="text-xs text-bambu-gray">
               <p>{printer.ip_address}</p>
               <p className="truncate">{printer.serial_number}</p>
             </div>
-            <div className="flex items-center gap-2">
+            <div className="flex items-center gap-2 flex-wrap">
               {/* Chamber Light Toggle */}
               <Button
                 variant="secondary"
@@ -4484,12 +4487,12 @@ export function PrintersPage() {
 
   return (
     <div className="p-4 md:p-8">
-      <div className="flex items-center justify-between mb-6">
+      <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
         <div>
           <h1 className="text-2xl font-bold text-white">Printers</h1>
           <StatusSummaryBar printers={printers} />
         </div>
-        <div className="flex items-center gap-3">
+        <div className="flex items-center gap-2 sm:gap-3 flex-wrap">
           {/* Sort dropdown */}
           <div className="flex items-center gap-1">
             <select
@@ -4648,6 +4651,7 @@ export function PrintersPage() {
                     timeFormat={settings?.time_format || 'system'}
                     cameraViewMode={settings?.camera_view_mode || 'window'}
                     onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
+                    checkPrinterFirmware={settings?.check_printer_firmware !== false}
                   />
                 ))}
               </div>
@@ -4676,6 +4680,7 @@ export function PrintersPage() {
               timeFormat={settings?.time_format || 'system'}
               cameraViewMode={settings?.camera_view_mode || 'window'}
               onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
+              checkPrinterFirmware={settings?.check_printer_firmware !== false}
             />
           ))}
         </div>

+ 11 - 3
frontend/src/pages/QueuePage.tsx

@@ -354,17 +354,25 @@ function SortableQueueItem({
           <div className="w-8" />
         )}
 
-        {/* Thumbnail */}
+        {/* Thumbnail - use plate-specific thumbnail if plate_id is set */}
         <div className="w-14 h-14 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
           {item.archive_thumbnail ? (
             <img
-              src={api.getArchiveThumbnail(item.archive_id!)}
+              src={
+                item.plate_id != null
+                  ? api.getArchivePlateThumbnail(item.archive_id!, item.plate_id)
+                  : api.getArchiveThumbnail(item.archive_id!)
+              }
               alt=""
               className="w-full h-full object-cover"
             />
           ) : item.library_file_thumbnail ? (
             <img
-              src={api.getLibraryFileThumbnailUrl(item.library_file_id!)}
+              src={
+                item.plate_id != null
+                  ? api.getLibraryFilePlateThumbnail(item.library_file_id!, item.plate_id)
+                  : api.getLibraryFileThumbnailUrl(item.library_file_id!)
+              }
               alt=""
               className="w-full h-full object-cover"
             />

+ 91 - 55
frontend/src/pages/SettingsPage.tsx

@@ -1,5 +1,5 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock } from 'lucide-react';
+import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { api } from '../api/client';
@@ -407,6 +407,7 @@ export function SettingsPage() {
       settings.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh ||
       settings.energy_tracking_mode !== localSettings.energy_tracking_mode ||
       settings.check_updates !== localSettings.check_updates ||
+      (settings.check_printer_firmware ?? true) !== (localSettings.check_printer_firmware ?? true) ||
       settings.notification_language !== localSettings.notification_language ||
       settings.ams_humidity_good !== localSettings.ams_humidity_good ||
       settings.ams_humidity_fair !== localSettings.ams_humidity_fair ||
@@ -469,6 +470,7 @@ export function SettingsPage() {
         energy_cost_per_kwh: localSettings.energy_cost_per_kwh,
         energy_tracking_mode: localSettings.energy_tracking_mode,
         check_updates: localSettings.check_updates,
+        check_printer_firmware: localSettings.check_printer_firmware,
         notification_language: localSettings.notification_language,
         ams_humidity_good: localSettings.ams_humidity_good,
         ams_humidity_fair: localSettings.ams_humidity_fair,
@@ -542,14 +544,16 @@ export function SettingsPage() {
   // Local state for camera URL inputs (to avoid saving on every keystroke)
   const [localCameraUrls, setLocalCameraUrls] = useState<Record<number, string>>({});
   const cameraUrlSaveTimeoutRef = useRef<Record<number, ReturnType<typeof setTimeout>>>({});
+  const initializedPrinterUrlsRef = useRef<Set<number>>(new Set());
 
   // Initialize local camera URLs from printer data
   useEffect(() => {
     if (printers) {
       const urls: Record<number, string> = {};
       printers.forEach(p => {
-        if (p.external_camera_url && localCameraUrls[p.id] === undefined) {
+        if (p.external_camera_url && !initializedPrinterUrlsRef.current.has(p.id)) {
           urls[p.id] = p.external_camera_url;
+          initializedPrinterUrlsRef.current.add(p.id);
         }
       });
       if (Object.keys(urls).length > 0) {
@@ -736,17 +740,20 @@ export function SettingsPage() {
                   <Globe className="w-4 h-4 inline mr-1" />
                   {t('settings.language')}
                 </label>
-                <select
-                  value={i18n.language}
-                  onChange={(e) => i18n.changeLanguage(e.target.value)}
-                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                >
-                  {availableLanguages.map((lang) => (
-                    <option key={lang.code} value={lang.code}>
-                      {lang.nativeName} ({lang.name})
-                    </option>
-                  ))}
-                </select>
+                <div className="relative">
+                  <select
+                    value={i18n.language}
+                    onChange={(e) => i18n.changeLanguage(e.target.value)}
+                    className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
+                  >
+                    {availableLanguages.map((lang) => (
+                      <option key={lang.code} value={lang.code}>
+                        {lang.nativeName} ({lang.name})
+                      </option>
+                    ))}
+                  </select>
+                  <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
+                </div>
                 <p className="text-xs text-bambu-gray mt-1">
                   {t('settings.languageDescription')}
                 </p>
@@ -755,17 +762,20 @@ export function SettingsPage() {
                 <label className="block text-sm text-bambu-gray mb-1">
                   {t('settings.defaultView')}
                 </label>
-                <select
-                  value={defaultView}
-                  onChange={(e) => handleDefaultViewChange(e.target.value)}
-                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                >
-                  {defaultNavItems.map((item) => (
-                    <option key={item.id} value={item.to}>
-                      {t(item.labelKey)}
-                    </option>
-                  ))}
-                </select>
+                <div className="relative">
+                  <select
+                    value={defaultView}
+                    onChange={(e) => handleDefaultViewChange(e.target.value)}
+                    className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
+                  >
+                    {defaultNavItems.map((item) => (
+                      <option key={item.id} value={item.to}>
+                        {t(item.labelKey)}
+                      </option>
+                    ))}
+                  </select>
+                  <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
+                </div>
                 <p className="text-xs text-bambu-gray mt-1">
                   {t('settings.defaultViewDescription')}
                 </p>
@@ -775,48 +785,57 @@ export function SettingsPage() {
                   <label className="block text-sm text-bambu-gray mb-1">
                     Date Format
                   </label>
-                  <select
-                    value={localSettings.date_format || 'system'}
-                    onChange={(e) => updateSetting('date_format', e.target.value as 'system' | 'us' | 'eu' | 'iso')}
-                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                  >
-                    <option value="system">System Default</option>
-                    <option value="us">US (MM/DD/YYYY)</option>
-                    <option value="eu">EU (DD/MM/YYYY)</option>
-                    <option value="iso">ISO (YYYY-MM-DD)</option>
-                  </select>
+                  <div className="relative">
+                    <select
+                      value={localSettings.date_format || 'system'}
+                      onChange={(e) => updateSetting('date_format', e.target.value as 'system' | 'us' | 'eu' | 'iso')}
+                      className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
+                    >
+                      <option value="system">System Default</option>
+                      <option value="us">US (MM/DD/YYYY)</option>
+                      <option value="eu">EU (DD/MM/YYYY)</option>
+                      <option value="iso">ISO (YYYY-MM-DD)</option>
+                    </select>
+                    <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
+                  </div>
                 </div>
                 <div>
                   <label className="block text-sm text-bambu-gray mb-1">
                     Time Format
                   </label>
-                  <select
-                    value={localSettings.time_format || 'system'}
-                    onChange={(e) => updateSetting('time_format', e.target.value as 'system' | '12h' | '24h')}
-                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                  >
-                    <option value="system">System Default</option>
-                    <option value="12h">12-hour (3:30 PM)</option>
-                    <option value="24h">24-hour (15:30)</option>
-                  </select>
+                  <div className="relative">
+                    <select
+                      value={localSettings.time_format || 'system'}
+                      onChange={(e) => updateSetting('time_format', e.target.value as 'system' | '12h' | '24h')}
+                      className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
+                    >
+                      <option value="system">System Default</option>
+                      <option value="12h">12-hour (3:30 PM)</option>
+                      <option value="24h">24-hour (15:30)</option>
+                    </select>
+                    <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
+                  </div>
                 </div>
               </div>
               <div>
                 <label className="block text-sm text-bambu-gray mb-1">
                   Default Printer
                 </label>
-                <select
-                  value={localSettings.default_printer_id ?? ''}
-                  onChange={(e) => updateSetting('default_printer_id', e.target.value ? Number(e.target.value) : null)}
-                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                >
-                  <option value="">No default (ask each time)</option>
-                  {printers?.map((printer) => (
-                    <option key={printer.id} value={printer.id}>
-                      {printer.name}
-                    </option>
-                  ))}
-                </select>
+                <div className="relative">
+                  <select
+                    value={localSettings.default_printer_id ?? ''}
+                    onChange={(e) => updateSetting('default_printer_id', e.target.value ? Number(e.target.value) : null)}
+                    className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
+                  >
+                    <option value="">No default (ask each time)</option>
+                    {printers?.map((printer) => (
+                      <option key={printer.id} value={printer.id}>
+                        {printer.name}
+                      </option>
+                    ))}
+                  </select>
+                  <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
+                </div>
                 <p className="text-xs text-bambu-gray mt-1">
                   Pre-select this printer for uploads, reprints, and other operations.
                 </p>
@@ -1296,6 +1315,23 @@ export function SettingsPage() {
                   <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
                 </label>
               </div>
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="text-white">Check printer firmware</p>
+                  <p className="text-sm text-bambu-gray">
+                    Check for printer firmware updates from Bambu Lab
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={localSettings.check_printer_firmware ?? true}
+                    onChange={(e) => updateSetting('check_printer_firmware', e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
               <div className="border-t border-bambu-dark-tertiary pt-4">
                 <div className="flex items-center justify-between mb-2">
                   <div>

+ 82 - 0
scripts/debug_preset.py

@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+"""Debug script to investigate Bambu Cloud preset API responses."""
+
+import asyncio
+import json
+import sys
+from pathlib import Path
+
+# Add backend to path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+import httpx
+from sqlalchemy import create_engine, text
+
+from backend.app.core.config import settings
+
+# Test preset IDs (from the warning logs)
+TEST_IDS = ["GFG02", "GFL05", "GFA00", "GFA02", "GFA06"]
+
+
+def get_token_from_db() -> str | None:
+    """Get the stored token from the database."""
+    db_path = settings.base_dir / "bambuddy.db"
+    engine = create_engine(f"sqlite:///{db_path}")
+
+    with engine.connect() as conn:
+        result = conn.execute(text("SELECT value FROM settings WHERE key = 'bambu_cloud_token'"))
+        row = result.fetchone()
+
+        if row and row[0]:
+            return row[0]
+    return None
+
+
+async def test_preset(setting_id: str, token: str, base_url: str = "https://api.bambulab.com"):
+    """Test fetching a single preset and show full response."""
+    url = f"{base_url}/v1/iot-service/api/slicer/setting/{setting_id}"
+    headers = {
+        "Authorization": f"Bearer {token}",
+        "Content-Type": "application/json",
+    }
+
+    print(f"\n{'=' * 60}")
+    print(f"Testing preset: {setting_id}")
+    print(f"URL: {url}")
+    print(f"{'=' * 60}")
+
+    async with httpx.AsyncClient() as client:
+        response = await client.get(url, headers=headers)
+
+        print(f"Status: {response.status_code}")
+        print("\nResponse body:")
+        try:
+            data = response.json()
+            print(json.dumps(data, indent=2))
+        except Exception:
+            print(response.text)
+
+    return response.status_code
+
+
+async def main():
+    # Get token from DB
+    token = get_token_from_db()
+
+    if not token:
+        print("Could not find token in database.")
+        print("Make sure you're logged into Bambu Cloud in Bambuddy.")
+        sys.exit(1)
+
+    print(f"Found token in database (length: {len(token)})")
+
+    # Allow testing specific preset IDs from command line
+    test_ids = sys.argv[1:] if len(sys.argv) > 1 else TEST_IDS
+
+    # Test each preset
+    for preset_id in test_ids:
+        await test_preset(preset_id, token)
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

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


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


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BvTstZiS.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-8OJBC-HQ.css">
+    <script type="module" crossorigin src="/assets/index-DpCP4Cea.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-0Pk0mZuT.css">
   </head>
   <body>
     <div id="root"></div>

+ 1 - 0
test_frontend.sh

@@ -1,5 +1,6 @@
 #!/bin/sh
 
 cd frontend
+npm run lint
 npm test
 cd ..

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