diff --git a/CHANGELOG.md b/CHANGELOG.md index 95d18a0f0..007ea8cd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ All notable changes to Bambuddy will be documented in this file. - **Print Log page: per-row delete (#1687 part 1, reported by @IndividualGhost1905)** — Reporter noted that the existing "Also remove this print from Quick Stats" toggle on archive delete is one-shot: if you tick "keep stats" at delete time, there was no later way to drop the row from /stats; and rows that aren't tied to an archive (errors, aborts, manual entries) had no delete affordance at all. **Fix:** every row in the Archives → Print Log table now has a trash icon next to the filament cell, gated on `archives:delete_own` (own rows) or `archives:delete_all` (any row), matching the archive-delete permission shape. Click → confirm modal → row is gone, and because /archives/stats aggregates over `PrintLogEntry` the filament / time / cost contribution drops out of Quick Stats in the same response cycle. The matching archive (if any) is untouched — the log row is a sibling, not a child. **Backend:** new `DELETE /print-log/{entry_id}` mirrors `delete_archive`'s ownership flow via `require_ownership_permission(ARCHIVES_DELETE_ALL, ARCHIVES_DELETE_OWN)`; owners can drop their own rows, admins can drop any row, missing IDs return 404 rather than 200-silently. **Frontend:** new `deletePrintLogEntry` API helper, per-row mutation that invalidates both `print-log` and `archives-stats` query keys so the totals re-render without a manual refresh. **i18n:** 4 new keys (`deleteEntryTitle`, `deleteEntryConfirm`, `entryDeleted`, `entryDeleteFailed`) translated across all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). **Tests:** 3 backend integration cases — delete drops the row from /stats while keeping the linked archive listed, missing ID returns 404, delete-one does not touch siblings (regression guard against an accidental `delete(PrintLogEntry)` without a `where`). Frontend ArchivesPage / PrintLogModal vitests stay green (31 / 31). i18n parity green (5099 leaves × 11 locales). Issue #1687 also asks for per-row tagging (already covered by `EditArchiveModal`'s tags field) and per-row filament-usage-history edits (deferred — see the issue thread for the reasoning). ### Fixed +- **System page now reports the container's uptime / boot time, not the host's (#1690, reported by @IndividualGhost1905)** — Reporter on Proxmox LXC observed that System → Uptime / Boot Time matched the Proxmox host's values, not the container's. **Root cause:** `psutil.boot_time()` reads `/proc/stat:btime`, which on shared-kernel containers (Docker, LXC) is the host kernel's boot time — leaking the host's lifecycle into Bambuddy's UI. **Fix:** read PID 1's create_time instead — `psutil.Process(1).create_time()` returns the POSIX timestamp of the init/entrypoint process, which in a container is the container's start time, and on bare metal / VMs is the host init (effectively identical to `psutil.boot_time()` within a sub-second). Defensive `psutil.Error` / `OSError` fallback to the old `psutil.boot_time()` for the rare case where /proc/1/stat is unreadable (locked-down container, custom seccomp policy). No frontend / i18n change — the field shape is unchanged, only the value is now correct on container installs. **Tests:** 2 new integration cases — one pins that the route reads `Process(1).create_time` and that the response uses that timestamp (not `boot_time`), the other pins the fallback path via a real `psutil.NoSuchProcess(1)` so the endpoint still returns 200 with the best-available answer. All 8 pre-existing system-info tests updated to also mock the new code path; full system API suite 20/20 green; ruff clean. + - **Profile editor filament type dropdown now lists PLA-CF and the other Bambu CF / GF / specialty materials (#1686, reported by @Bgabor997)** — Creating or editing a filament preset on the Profiles page (BL Cloud, Orca Cloud, and Local Profiles all open the same shared editor) only offered 11 base materials (PLA, ABS, PETG, TPU, PA, PA-CF, PET-CF, PC, ASA, PVA, HIPS). Reporter on P1S wanted to tag a custom preset as PLA-CF — the dropdown source had no entry, so the saved preset's `filament_type` was wrong and the printer received the wrong material code at dispatch. **Root cause:** `backend/app/data/filament_fields.json` (served by `GET /cloud/fields/filament` and consumed by `ProfilesPage` via `getCloudFields`) shipped a curated subset that pre-dated Bambu's CF/GF lineup expansion. Other surfaces in the codebase already named the canonical list (`utils/filament_ids.py` `GENERIC_FILAMENT_IDS`, `spool-form/utils.ts` MATERIALS, the Bambu filament-id catalog in `cloud.py`), so the gap was specifically in the editor's allowed-values JSON. **Fix:** expanded the `filament_type` select to 25 BambuStudio-aligned options grouped by family — PLA (+ CF/GF/AERO), PETG (+ CF), ABS (+ GF), ASA (+ CF/GF), PC, PCTG, PA family (+ CF/PAHT-CF/PA6-CF/PA6-GF), PET-CF, TPU, PPS family (+ CF/GF for X1E), PVA, HIPS. No frontend, no i18n (material codes are universal). K-profiles editor unaffected — it picks `filament_id`, not `filament_type`. **Tests:** 15 unit cases in `test_filament_fields_options.py` pin every newly-added variant (PLA-CF, PLA-GF, PLA-AERO, PETG-CF, ABS-GF, ASA-CF, ASA-GF, PCTG, PAHT-CF, PA6-CF, PA6-GF, PPS, PPS-CF, PPS-GF) plus the baseline-must-still-be-present guard so a future curation pass can't silently drop them. - **Native systemd install no longer fails when INSTALL_PATH is under /home (#1685, reported by @Geoff-S)** — `bambuddy.service` shipped with `ProtectHome=true`, which makes `/home/*` invisible to the service namespace. When the user installed into `/home/bambuddy/` (instead of the default `/opt/bambuddy/`), the `ExecStart=/home/bambuddy/venv/bin/uvicorn` path couldn't be resolved at exec time and the unit failed with `status=203/EXEC: Unable to locate executable`. The `ReadWritePaths=$INSTALL_PATH` directive doesn't reliably re-expose `/home/*` subpaths for executable resolution. **Fix:** `install/install.sh` now detects `INSTALL_PATH == /home/*` and emits `ProtectHome=read-only` for that case; the default `/opt/bambuddy/` install keeps the stricter `ProtectHome=true`. The manual `deploy/bambuddy.service` template defaults to `ProtectHome=read-only` with a comment explaining when to tighten it to `true`. `read-only` keeps `/home` immutable to the service (no security regression — the service can read its venv but not write anywhere outside the `ReadWritePaths` allowlist). diff --git a/backend/app/api/routes/system.py b/backend/app/api/routes/system.py index 1a563f92a..605eb20e1 100644 --- a/backend/app/api/routes/system.py +++ b/backend/app/api/routes/system.py @@ -498,7 +498,15 @@ async def get_system_info( # System info memory = psutil.virtual_memory() - boot_time = datetime.fromtimestamp(psutil.boot_time()) + # PID 1's create_time is the right uptime anchor in containerised installs + # (Docker, LXC) — psutil.boot_time() reads /proc/stat:btime which on a + # shared-kernel container is the host's boot time, not the container's + # (#1690). On bare metal / VMs PID 1 is the host init, which starts at + # boot, so the value matches psutil.boot_time() within a sub-second. + try: + boot_time = datetime.fromtimestamp(psutil.Process(1).create_time()) + except (psutil.Error, OSError): + boot_time = datetime.fromtimestamp(psutil.boot_time()) uptime_seconds = (datetime.now() - boot_time).total_seconds() # Python and system info diff --git a/backend/tests/integration/test_system_api.py b/backend/tests/integration/test_system_api.py index c22120fce..70484634d 100644 --- a/backend/tests/integration/test_system_api.py +++ b/backend/tests/integration/test_system_api.py @@ -29,6 +29,7 @@ class TestSystemAPI: total=16000000000, available=8000000000, used=8000000000, percent=50.0 ) mock_psutil.boot_time.return_value = 1700000000.0 + mock_psutil.Process.return_value.create_time.return_value = 1700000000.0 mock_psutil.cpu_count.return_value = 4 mock_psutil.cpu_percent.return_value = 25.0 @@ -58,6 +59,7 @@ class TestSystemAPI: total=16000000000, available=8000000000, used=8000000000, percent=50.0 ) mock_psutil.boot_time.return_value = 1700000000.0 + mock_psutil.Process.return_value.create_time.return_value = 1700000000.0 mock_psutil.cpu_count.return_value = 4 mock_psutil.cpu_percent.return_value = 25.0 @@ -82,6 +84,7 @@ class TestSystemAPI: total=16000000000, available=8000000000, used=8000000000, percent=50.0 ) mock_psutil.boot_time.return_value = 1700000000.0 + mock_psutil.Process.return_value.create_time.return_value = 1700000000.0 mock_psutil.cpu_count.return_value = 4 mock_psutil.cpu_percent.return_value = 25.0 @@ -114,6 +117,7 @@ class TestSystemAPI: total=16000000000, available=8000000000, used=8000000000, percent=50.0 ) mock_psutil.boot_time.return_value = 1700000000.0 + mock_psutil.Process.return_value.create_time.return_value = 1700000000.0 mock_psutil.cpu_count.return_value = 4 mock_psutil.cpu_percent.return_value = 25.0 @@ -144,6 +148,7 @@ class TestSystemAPI: total=16000000000, available=8000000000, used=8000000000, percent=50.0 ) mock_psutil.boot_time.return_value = 1700000000.0 + mock_psutil.Process.return_value.create_time.return_value = 1700000000.0 mock_psutil.cpu_count.return_value = 4 mock_psutil.cpu_percent.return_value = 25.0 @@ -170,6 +175,7 @@ class TestSystemAPI: total=16000000000, available=8000000000, used=8000000000, percent=50.0 ) mock_psutil.boot_time.return_value = 1700000000.0 + mock_psutil.Process.return_value.create_time.return_value = 1700000000.0 mock_psutil.cpu_count.return_value = 4 mock_psutil.cpu_percent.return_value = 25.0 @@ -200,6 +206,7 @@ class TestSystemAPI: total=16000000000, available=8000000000, used=8000000000, percent=50.0 ) mock_psutil.boot_time.return_value = 1700000000.0 + mock_psutil.Process.return_value.create_time.return_value = 1700000000.0 mock_psutil.cpu_count.return_value = 4 mock_psutil.cpu_percent.return_value = 25.0 @@ -257,6 +264,7 @@ class TestSystemAPI: total=16000000000, available=8000000000, used=8000000000, percent=50.0 ) mock_psutil.boot_time.return_value = 1700000000.0 + mock_psutil.Process.return_value.create_time.return_value = 1700000000.0 mock_psutil.cpu_count.return_value = 4 mock_psutil.cpu_percent.return_value = 25.0 mock_pm._clients = {} @@ -271,6 +279,63 @@ class TestSystemAPI: assert db_info["archives_failed"] >= 1 assert db_info["total_print_time_seconds"] >= 5400 + @pytest.mark.asyncio + @pytest.mark.integration + async def test_boot_time_uses_pid1_create_time(self, async_client: AsyncClient): + """#1690: container installs (Docker/LXC) share the host kernel, so + psutil.boot_time() returns the host's boot time instead of the + container's. Reading PID 1's create_time gives the container start + time on containers and matches host boot on bare metal.""" + with patch("backend.app.api.routes.system.psutil") as mock_psutil: + mock_psutil.disk_usage.return_value = MagicMock( + total=500000000000, used=250000000000, free=250000000000, percent=50.0 + ) + mock_psutil.virtual_memory.return_value = MagicMock( + total=16000000000, available=8000000000, used=8000000000, percent=50.0 + ) + # Host boot is FOUR DAYS earlier than the container's PID 1 start. + # The route must report the PID 1 value, not the host value. + mock_psutil.boot_time.return_value = 1700000000.0 + mock_psutil.Process.return_value.create_time.return_value = 1700345600.0 + mock_psutil.cpu_count.return_value = 4 + mock_psutil.cpu_percent.return_value = 25.0 + + response = await async_client.get("/api/v1/system/info") + + assert response.status_code == 200 + result = response.json() + assert result["system"]["boot_time"].startswith("2023-11-18T") # 1700345600 UTC + # PID 1 was queried with pid=1 (not the worker pid). + mock_psutil.Process.assert_called_with(1) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_boot_time_falls_back_to_psutil_boot_time_on_pid1_failure(self, async_client: AsyncClient): + """If PID 1 is unreadable (rare — locked-down container, /proc not + mounted), fall back to psutil.boot_time() so the endpoint still + returns 200 with the best available answer.""" + import psutil as real_psutil + + with patch("backend.app.api.routes.system.psutil") as mock_psutil: + mock_psutil.disk_usage.return_value = MagicMock( + total=500000000000, used=250000000000, free=250000000000, percent=50.0 + ) + mock_psutil.virtual_memory.return_value = MagicMock( + total=16000000000, available=8000000000, used=8000000000, percent=50.0 + ) + mock_psutil.boot_time.return_value = 1700000000.0 + # Use the real exception classes so the route's except clause matches. + mock_psutil.Error = real_psutil.Error + mock_psutil.Process.side_effect = real_psutil.NoSuchProcess(1) + mock_psutil.cpu_count.return_value = 4 + mock_psutil.cpu_percent.return_value = 25.0 + + response = await async_client.get("/api/v1/system/info") + + assert response.status_code == 200 + result = response.json() + assert result["system"]["boot_time"].startswith("2023-11-14T") # 1700000000 UTC + class TestSystemHelperFunctions: """Tests for system info helper functions."""