diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1354c1ef3..04d2cf559 100644 --- a/.github/workflows/ci.yml +++ b/.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: diff --git a/.github/workflows/issue-closed.yml b/.github/workflows/issue-closed.yml new file mode 100644 index 000000000..31f2e607b --- /dev/null +++ b/.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; + } + } + } diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 4ada6255e..03e2b7824 100644 --- a/.github/workflows/stale.yml +++ b/.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' diff --git a/CHANGELOG.md b/CHANGELOG.md index 7beef5866..65921562a 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/README.md b/README.md index 230b20908..18b027125 100644 --- a/README.md +++ b/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 diff --git a/backend/.coverage b/backend/.coverage deleted file mode 100644 index 811e3bd30..000000000 Binary files a/backend/.coverage and /dev/null differ diff --git a/backend/app/api/routes/cloud.py b/backend/app/api/routes/cloud.py index 36ebf38d3..f1dc73cc5 100644 --- a/backend/app/api/routes/cloud.py +++ b/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} diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index 6e8f86a92..811272c66 100644 --- a/backend/app/api/routes/printers.py +++ b/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, diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 7ea8e7061..b687d3ff9 100644 --- a/backend/app/api/routes/settings.py +++ b/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", diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 0e41fa820..75de9a310 100644 --- a/backend/app/schemas/settings.py +++ b/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 diff --git a/backend/app/services/github_backup.py b/backend/app/services/github_backup.py index e7e1bfb1f..d499948a7 100644 --- a/backend/app/services/github_backup.py +++ b/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, } diff --git a/backend/app/services/printer_manager.py b/backend/app/services/printer_manager.py index ace0e8106..4457b5fb4 100644 --- a/backend/app/services/printer_manager.py +++ b/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) diff --git a/backend/app/services/spoolman.py b/backend/app/services/spoolman.py index 3be407df1..bf95ae8ca 100644 --- a/backend/app/services/spoolman.py +++ b/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() == "": diff --git a/backend/tests/integration/test_settings_api.py b/backend/tests/integration/test_settings_api.py index 243d3cc1e..01c69efad 100644 --- a/backend/tests/integration/test_settings_api.py +++ b/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 # ======================================================================== diff --git a/backend/tests/unit/services/test_printer_manager.py b/backend/tests/unit/services/test_printer_manager.py index dbff3358e..25547f08b 100644 --- a/backend/tests/unit/services/test_printer_manager.py +++ b/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.""" diff --git a/bambuddy-issue-notes.txt b/bambuddy-issue-notes.txt deleted file mode 100644 index 71c70f4c0..000000000 --- a/bambuddy-issue-notes.txt +++ /dev/null @@ -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) diff --git a/deploy/bambuddy.service b/deploy/bambuddy.service index cf30af7aa..b096b3d29 100644 --- a/deploy/bambuddy.service +++ b/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=/bambuddy +Environment="PATH=/bambuddy/logs +ExecStartPre=+/bin/mkdir -p /bambuddy/archive +ExecStartPre=+/bin/chown -R : /bambuddy/logs +ExecStartPre=+/bin/chown -R : /bambuddy/archive -ExecStart=/opt/claude/projects/bambuddy/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 +ExecStart=/bambuddy/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 Restart=always RestartSec=10 diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 4f353f6ae..2cea7df7b 100644 --- a/frontend/eslint.config.js +++ b/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: [ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c2278e1be..c6b2bae1d 100644 --- a/frontend/package-lock.json +++ b/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": { - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" + "@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/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==", + "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": { - "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": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "dependencies": { + "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" - ], - "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==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "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", + "@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.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.9", + "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": "^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", diff --git a/frontend/package.json b/frontend/package.json index c3b67f51e..14a4c0903 100644 --- a/frontend/package.json +++ b/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" } } diff --git a/frontend/src/__tests__/api/githubBackupApi.test.ts b/frontend/src/__tests__/api/githubBackupApi.test.ts index 851ba074b..d134ae3bd 100644 --- a/frontend/src/__tests__/api/githubBackupApi.test.ts +++ b/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 { diff --git a/frontend/src/__tests__/components/Layout.test.tsx b/frontend/src/__tests__/components/Layout.test.tsx index f32634762..092a59a1b 100644 --- a/frontend/src/__tests__/components/Layout.test.tsx +++ b/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, }); }), diff --git a/frontend/src/__tests__/pages/ArchivesPage.test.tsx b/frontend/src/__tests__/pages/ArchivesPage.test.tsx index 3ae8949a3..0b19a0fd1 100644 --- a/frontend/src/__tests__/pages/ArchivesPage.test.tsx +++ b/frontend/src/__tests__/pages/ArchivesPage.test.tsx @@ -261,4 +261,44 @@ describe('ArchivesPage', () => { }); }); }); + + describe('plate navigation', () => { + it('renders archive cards with thumbnails', async () => { + render(); + + 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(); + + 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 + }); + }); }); diff --git a/frontend/src/__tests__/pages/FileManagerPage.test.tsx b/frontend/src/__tests__/pages/FileManagerPage.test.tsx index 4dab4ce21..d6951be14 100644 --- a/frontend/src/__tests__/pages/FileManagerPage.test.tsx +++ b/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, }); }), diff --git a/frontend/src/__tests__/pages/QueuePage.test.tsx b/frontend/src/__tests__/pages/QueuePage.test.tsx index 3b958ad39..dbb7a792a 100644 --- a/frontend/src/__tests__/pages/QueuePage.test.tsx +++ b/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(); + + await waitFor(() => { + expect(screen.getByText('Multi-plate Print')).toBeInTheDocument(); + }); + }); }); describe('empty state', () => { diff --git a/frontend/src/__tests__/pages/SettingsPage.test.tsx b/frontend/src/__tests__/pages/SettingsPage.test.tsx index 9f318f7b6..45c40ad8e 100644 --- a/frontend/src/__tests__/pages/SettingsPage.test.tsx +++ b/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(); + + await waitFor(() => { + expect(screen.getByText('Updates')).toBeInTheDocument(); + expect(screen.getByText('Check for updates')).toBeInTheDocument(); + expect(screen.getByText('Check printer firmware')).toBeInTheDocument(); + }); + }); }); describe('tabs navigation', () => { diff --git a/frontend/src/__tests__/pages/StatsPage.test.tsx b/frontend/src/__tests__/pages/StatsPage.test.tsx index e86c2e2f7..d1c193be0 100644 --- a/frontend/src/__tests__/pages/StatsPage.test.tsx +++ b/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 = { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5e08f3101..94e403982 100644 --- a/frontend/src/api/client.ts +++ b/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', { diff --git a/frontend/src/components/GitHubBackupSettings.tsx b/frontend/src/components/GitHubBackupSettings.tsx index 89053b88e..5c00a8b57 100644 --- a/frontend/src/components/GitHubBackupSettings.tsx +++ b/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', 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; diff --git a/frontend/src/pages/ArchivesPage.tsx b/frontend/src/pages/ArchivesPage.tsx index bb1c9b58a..59673ab1a 100644 --- a/frontend/src/pages/ArchivesPage.tsx +++ b/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(null); + const [showPlateNav, setShowPlateNav] = useState(false); const source3mfInputRef = useRef(null); const f3dInputRef = useRef(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({ )} - {/* Thumbnail */} -
+ {/* Thumbnail with plate navigation */} +
setShowPlateNav(true)} + onMouseLeave={() => setShowPlateNav(false)} + > {archive.thumbnail_path ? ( 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({
)} + {/* Plate navigation - only show for multi-plate archives */} + {isMultiPlate && plates.length > 1 && ( + <> + {/* Left arrow */} + + {/* Right arrow */} + + {/* Dots indicator */} +
+ {plates.map((plate, idx) => ( +
+ + )} {/* Context menu button - visible on mobile, shows on hover for desktop */}