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

Merge branch '0.2.0b' into feature/ui_improvements

MartinNYHC 3 месяцев назад
Родитель
Сommit
957e57f689

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

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

+ 5 - 0
CHANGELOG.md

@@ -17,6 +17,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **K-Profiles View — Accurate Filament Name Resolution** — K-profile filament names are now resolved from builtin filament tables and user cloud presets (via new `/cloud/filament-id-map` endpoint) instead of showing raw IDs like "GFU99" or "P4d64437". Falls back to extracting names from the profile name field.
 - **K-Profiles View — Accurate Filament Name Resolution** — K-profile filament names are now resolved from builtin filament tables and user cloud presets (via new `/cloud/filament-id-map` endpoint) instead of showing raw IDs like "GFU99" or "P4d64437". Falls back to extracting names from the profile name field.
 - **Print Log** — New view mode on the Archives page showing a chronological table of all print activity. Columns include date/time, print name, printer, user, status, duration, and filament. Supports filtering by search text, printer, user, status, and date range. Pagination with configurable page size. A dedicated clear button deletes only log entries without affecting archives. Data is stored in a separate `print_log_entries` database table.
 - **Print Log** — New view mode on the Archives page showing a chronological table of all print activity. Columns include date/time, print name, printer, user, status, duration, and filament. Supports filtering by search text, printer, user, status, and date range. Pagination with configurable page size. A dedicated clear button deletes only log entries without affecting archives. Data is stored in a separate `print_log_entries` database table.
 - **Sync Spool Weights from AMS** — New button in Settings → Filament Tracking (built-in inventory mode) to force-sync all inventory spool weights from the live AMS remain% values of connected printers. Overwrites the database weight data with current sensor readings. Useful for recovering from corrupted weight data (e.g., after a power-off event zeroed all fill levels). Requires printers to be online. Includes a confirmation modal.
 - **Sync Spool Weights from AMS** — New button in Settings → Filament Tracking (built-in inventory mode) to force-sync all inventory spool weights from the live AMS remain% values of connected printers. Overwrites the database weight data with current sensor readings. Useful for recovering from corrupted weight data (e.g., after a power-off event zeroed all fill levels). Requires printers to be online. Includes a confirmation modal.
+- **Notification Thumbnails for Telegram & ntfy** ([#372](https://github.com/maziggy/bambuddy/issues/372)) — Print thumbnail images are now attached to Telegram and ntfy notifications (previously only Pushover and Discord). Telegram uses the `sendPhoto` API with the image as caption attachment. ntfy sends the image as a binary PUT with `Filename` and `Message` headers. No configuration needed — images are sent automatically when available.
 
 
 ### Fixed
 ### Fixed
 - **Firmware Upload Uses Wrong Filename on Cache Hit** — The firmware update uploader cached downloaded firmware files under a mangled name (e.g., `X1C_01_09_00_10.bin`) instead of the original filename from Bambu Lab's CDN. On the first download the correct filename was uploaded to the SD card, but on subsequent attempts the cached file with the wrong name was used — causing the printer to not recognize the firmware file. Now caches using the original filename so the SD card always receives the correct file.
 - **Firmware Upload Uses Wrong Filename on Cache Hit** — The firmware update uploader cached downloaded firmware files under a mangled name (e.g., `X1C_01_09_00_10.bin`) instead of the original filename from Bambu Lab's CDN. On the first download the correct filename was uploaded to the SD card, but on subsequent attempts the cached file with the wrong name was used — causing the printer to not recognize the firmware file. Now caches using the original filename so the SD card always receives the correct file.
@@ -38,9 +39,13 @@ All notable changes to Bambuddy will be documented in this file.
 - **Spool Edit Form Overwrites Usage-Tracked Weight** — Editing any spool field (note, color, material, etc.) sent the full form data back to the server, including `weight_used`. If the frontend cache was stale (e.g., loaded before the last print completed), saving the form would silently reset `weight_used` to the pre-print value, reverting the remaining weight to full. The form now only includes `weight_used` in the update request when the user explicitly changes the weight field.
 - **Spool Edit Form Overwrites Usage-Tracked Weight** — Editing any spool field (note, color, material, etc.) sent the full form data back to the server, including `weight_used`. If the frontend cache was stale (e.g., loaded before the last print completed), saving the form would silently reset `weight_used` to the pre-print value, reverting the remaining weight to full. The form now only includes `weight_used` in the update request when the user explicitly changes the weight field.
 - **K-Profile Auto-Select Fails for Non-BL Spools on Dual-Nozzle Printers** — When assigning a third-party spool to an AMS slot on dual-nozzle printers (H2D, H2D Pro), the MQTT auto-configure step crashed with `'SpoolKProfile' object has no attribute 'extruder_id'`. The K-profile model uses `extruder` (not `extruder_id`). Fixed the attribute name so K-profile matching correctly filters by nozzle on dual-extruder printers.
 - **K-Profile Auto-Select Fails for Non-BL Spools on Dual-Nozzle Printers** — When assigning a third-party spool to an AMS slot on dual-nozzle printers (H2D, H2D Pro), the MQTT auto-configure step crashed with `'SpoolKProfile' object has no attribute 'extruder_id'`. The K-profile model uses `extruder` (not `extruder_id`). Fixed the attribute name so K-profile matching correctly filters by nozzle on dual-extruder printers.
 - **Loose Archive Name Matching Could Cause Wrong Archive Reuse** ([#374](https://github.com/maziggy/bambuddy/issues/374)) — The `on_print_start` callback used `ilike('%{name}%')` to find existing "printing" archives, which meant a print named "Clip" could incorrectly match "Cable Clip" or "Clip Stand". This could cause a new print to reuse the wrong archive or skip creating one. Tightened to exact `print_name` match or exact filename variants (`.3mf`, `.gcode.3mf`).
 - **Loose Archive Name Matching Could Cause Wrong Archive Reuse** ([#374](https://github.com/maziggy/bambuddy/issues/374)) — The `on_print_start` callback used `ilike('%{name}%')` to find existing "printing" archives, which meant a print named "Clip" could incorrectly match "Cable Clip" or "Clip Stand". This could cause a new print to reuse the wrong archive or skip creating one. Tightened to exact `print_name` match or exact filename variants (`.3mf`, `.gcode.3mf`).
+- **Phantom Prints on Power Cycle** ([#374](https://github.com/maziggy/bambuddy/issues/374)) — The print queue uploaded `.3mf` files to the printer's SD card root (`/`) but never deleted them after the print finished. Some printers (e.g. P1S) auto-start files found in the root directory on power cycle, causing ghost prints on every reboot. Now deletes the uploaded file from the SD card after print completion (best-effort, non-blocking).
+- **Spool Form Scrollbar Flicker in Edge** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — The Add/Edit Spool modal's scrollable area used `overflow-y: auto`, which on Windows Edge (where scrollbars take layout space) caused the scrollbar to appear and disappear on hover — making the color picker unusable at certain zoom levels. Added `scrollbar-gutter: stable` to reserve scrollbar space and prevent layout thrashing.
 - **Archive Duplicate Badge Misses Name-Based Duplicates** ([#315](https://github.com/maziggy/bambuddy/issues/315)) — The duplicate badge on archive cards only matched by file content hash, so re-sliced prints of the same model (different GCODE, same print name) were not flagged as duplicates. Now also matches by print name (case-insensitive), consistent with the detail view's duplicate detection.
 - **Archive Duplicate Badge Misses Name-Based Duplicates** ([#315](https://github.com/maziggy/bambuddy/issues/315)) — The duplicate badge on archive cards only matched by file content hash, so re-sliced prints of the same model (different GCODE, same print name) were not flagged as duplicates. Now also matches by print name (case-insensitive), consistent with the detail view's duplicate detection.
+- **Schedule Print Allows No Plate Selected for Multi-Plate Files** ([#394](https://github.com/maziggy/bambuddy/issues/394)) — When scheduling a multi-plate file from the file manager, the modal showed a "Selection required" warning but still allowed submission without selecting a plate. The job defaulted to plate 1, but the queue item didn't indicate which plate, and editing showed no plate selected. Now auto-selects the first plate by default when plates load, and the submit button validation applies to both archive and library files.
 
 
 ### Improved
 ### Improved
+- **Skip Objects: Click-to-Enlarge Lightbox** ([#396](https://github.com/maziggy/bambuddy/issues/396)) — The skip objects modal's small 208px image panel made it difficult to distinguish object markers when parts are small or close together. Clicking the image now opens a fullscreen lightbox overlay with the same image and markers at a much larger size (up to 600px). The 24px marker circles are proportionally smaller relative to the enlarged image, solving the overlap problem. Close via X button, Escape key, or clicking the backdrop. Escape cascades correctly — closes lightbox first, then the modal.
 - **Phantom Print Investigation — Logging & Hardening** ([#374](https://github.com/maziggy/bambuddy/issues/374)) — Added targeted logging and hardening to help diagnose reports of prints starting automatically without user input. Debug log volume reduced ~90% by suppressing `sqlalchemy.engine` (changed from INFO to WARNING) and `aiosqlite` (new WARNING suppression) noise that previously filled 2.5MB in 16 minutes. Every `start_print()` call now logs a `PRINT COMMAND` trace with the caller's file, line, and function name. The print scheduler logs pending queue items when found. `on_print_complete` warns when multiple queue items are in "printing" status for the same printer, which signals a state inconsistency.
 - **Phantom Print Investigation — Logging & Hardening** ([#374](https://github.com/maziggy/bambuddy/issues/374)) — Added targeted logging and hardening to help diagnose reports of prints starting automatically without user input. Debug log volume reduced ~90% by suppressing `sqlalchemy.engine` (changed from INFO to WARNING) and `aiosqlite` (new WARNING suppression) noise that previously filled 2.5MB in 16 minutes. Every `start_print()` call now logs a `PRINT COMMAND` trace with the caller's file, line, and function name. The print scheduler logs pending queue items when found. `on_print_complete` warns when multiple queue items are in "printing" status for the same printer, which signals a state inconsistency.
 - **Reduce Log Noise from MQTT Diagnostics** ([#365](https://github.com/maziggy/bambuddy/issues/365)) — Downgraded 58 high-frequency MQTT diagnostic messages from INFO to DEBUG level. Payload dumps, detector state changes, field discovery logs, H2D disambiguation, and periodic status updates no longer flood the log at the default INFO level. Also suppresses paho-mqtt library INFO messages in production. User-initiated actions (print start/stop, AMS load/unload, calibration) remain at INFO. All diagnostic detail is still available when debug logging is enabled.
 - **Reduce Log Noise from MQTT Diagnostics** ([#365](https://github.com/maziggy/bambuddy/issues/365)) — Downgraded 58 high-frequency MQTT diagnostic messages from INFO to DEBUG level. Payload dumps, detector state changes, field discovery logs, H2D disambiguation, and periodic status updates no longer flood the log at the default INFO level. Also suppresses paho-mqtt library INFO messages in production. User-initiated actions (print start/stop, AMS load/unload, calibration) remain at INFO. All diagnostic detail is still available when debug logging is enabled.
 - **SQLite WAL Mode for Database Reliability** — Database now uses Write-Ahead Logging (WAL) mode with a 5-second busy timeout, reducing "database is locked" errors under concurrent access. WAL mode allows simultaneous reads during writes, improving responsiveness for multi-printer setups. Automatically enabled on startup.
 - **SQLite WAL Mode for Database Reliability** — Database now uses Write-Ahead Logging (WAL) mode with a 5-second busy timeout, reducing "database is locked" errors under concurrent access. WAL mode allows simultaneous reads during writes, improving responsiveness for multi-printer setups. Automatically enabled on startup.

+ 11 - 0
backend/app/api/routes/print_queue.py

@@ -31,6 +31,7 @@ from backend.app.schemas.print_queue import (
 )
 )
 from backend.app.services.notification_service import notification_service
 from backend.app.services.notification_service import notification_service
 from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
 from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
+from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -205,12 +206,16 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         response.archive_name = item.archive.print_name or item.archive.filename
         response.archive_name = item.archive.print_name or item.archive.filename
         response.archive_thumbnail = item.archive.thumbnail_path
         response.archive_thumbnail = item.archive.thumbnail_path
         response.print_time_seconds = item.archive.print_time_seconds
         response.print_time_seconds = item.archive.print_time_seconds
+        response.filament_used_grams = item.archive.filament_used_grams
         if item.plate_id:
         if item.plate_id:
             archive_path = settings.base_dir / item.archive.file_path
             archive_path = settings.base_dir / item.archive.file_path
             if archive_path.exists():
             if archive_path.exists():
                 plate_time = _extract_print_time_from_3mf(archive_path, item.plate_id)
                 plate_time = _extract_print_time_from_3mf(archive_path, item.plate_id)
+                plate_weight = sum(f["used_g"] for f in extract_filament_usage_from_3mf(archive_path, item.plate_id))
                 if plate_time is not None:
                 if plate_time is not None:
                     response.print_time_seconds = plate_time
                     response.print_time_seconds = plate_time
+                if plate_weight > 0:
+                    response.filament_used_grams = plate_weight
     if item.library_file:
     if item.library_file:
         response.library_file_name = (
         response.library_file_name = (
             item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None
             item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None
@@ -221,13 +226,19 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         # Get print time from library file metadata if no archive
         # Get print time from library file metadata if no archive
         if not item.archive and item.library_file.file_metadata:
         if not item.archive and item.library_file.file_metadata:
             response.print_time_seconds = item.library_file.file_metadata.get("print_time_seconds")
             response.print_time_seconds = item.library_file.file_metadata.get("print_time_seconds")
+            response.filament_used_grams = item.library_file.file_metadata.get("filament_used_grams")
         if item.plate_id:
         if item.plate_id:
             lib_path = Path(item.library_file.file_path)
             lib_path = Path(item.library_file.file_path)
             library_file_path = lib_path if lib_path.is_absolute() else settings.base_dir / item.library_file.file_path
             library_file_path = lib_path if lib_path.is_absolute() else settings.base_dir / item.library_file.file_path
             if library_file_path.exists():
             if library_file_path.exists():
                 plate_time = _extract_print_time_from_3mf(library_file_path, item.plate_id)
                 plate_time = _extract_print_time_from_3mf(library_file_path, item.plate_id)
+                plate_weight = sum(
+                    f["used_g"] for f in extract_filament_usage_from_3mf(library_file_path, item.plate_id)
+                )
                 if plate_time is not None:
                 if plate_time is not None:
                     response.print_time_seconds = plate_time
                     response.print_time_seconds = plate_time
+                if plate_weight > 0:
+                    response.filament_used_grams = plate_weight
     if item.printer:
     if item.printer:
         response.printer_name = item.printer.name
         response.printer_name = item.printer.name
     return response
     return response

+ 25 - 0
backend/app/main.py

@@ -2759,6 +2759,31 @@ async def on_print_complete(printer_id: int, data: dict):
         logging.getLogger(__name__).warning(f"Queue item update failed: {e}")
         logging.getLogger(__name__).warning(f"Queue item update failed: {e}")
 
 
     log_timing("Queue item update")
     log_timing("Queue item update")
+
+    # Cleanup: delete uploaded file from printer SD card to prevent phantom prints (Issue #374)
+    # The print scheduler uploads .3mf files to the SD card root (/). Some printers (e.g. P1S)
+    # auto-start files found in root on power cycle, causing ghost prints.
+    try:
+        printer_info = printer_manager.get_printer(printer_id)
+        if printer_info and subtask_name:
+            from backend.app.services.bambu_ftp import delete_file_async
+
+            remote_path = f"/{subtask_name}.3mf"
+            logger.info("Cleaning up uploaded file from printer SD card: %s", remote_path)
+            delete_result = await delete_file_async(
+                printer_info.ip_address,
+                printer_info.access_code,
+                remote_path,
+                printer_model=printer_info.model,
+            )
+            if delete_result:
+                logger.info("Deleted %s from printer %s SD card", remote_path, printer_info.name)
+            else:
+                logger.debug("File delete returned False for %s (may not exist)", remote_path)
+    except Exception as e:
+        logger.debug("SD card file cleanup failed for printer %s: %s (non-critical)", printer_id, e)
+
+    log_timing("SD card cleanup")
     logger.info("[CALLBACK] on_print_complete finished for printer %s, archive %s", printer_id, archive_id)
     logger.info("[CALLBACK] on_print_complete finished for printer %s, archive %s", printer_id, archive_id)
 
 
 
 

+ 1 - 0
backend/app/schemas/print_queue.py

@@ -97,6 +97,7 @@ class PrintQueueItemResponse(BaseModel):
     library_file_thumbnail: str | None = None  # Thumbnail of library file
     library_file_thumbnail: str | None = None  # Thumbnail of library file
     printer_name: str | None = None
     printer_name: str | None = None
     print_time_seconds: int | None = None  # Estimated print time from archive or library file
     print_time_seconds: int | None = None  # Estimated print time from archive or library file
+    filament_used_grams: float | None = None  # Estimated print weight from archive or library file
 
 
     # User tracking (Issue #206)
     # User tracking (Issue #206)
     created_by_id: int | None = None
     created_by_id: int | None = None

+ 31 - 14
backend/app/services/notification_service.py

@@ -188,7 +188,9 @@ class NotificationService:
         else:
         else:
             return False, f"HTTP {response.status_code}: {response.text[:200]}"
             return False, f"HTTP {response.status_code}: {response.text[:200]}"
 
 
-    async def _send_ntfy(self, config: dict, title: str, message: str) -> tuple[bool, str]:
+    async def _send_ntfy(
+        self, config: dict, title: str, message: str, image_data: bytes | None = None
+    ) -> tuple[bool, str]:
         """Send notification via ntfy."""
         """Send notification via ntfy."""
         server = config.get("server", "https://ntfy.sh").rstrip("/")
         server = config.get("server", "https://ntfy.sh").rstrip("/")
         topic = config.get("topic", "").strip()
         topic = config.get("topic", "").strip()
@@ -204,7 +206,14 @@ class NotificationService:
             headers["Authorization"] = f"Bearer {auth_token}"
             headers["Authorization"] = f"Bearer {auth_token}"
 
 
         client = await self._get_client()
         client = await self._get_client()
-        response = await client.post(url, content=message, headers=headers)
+
+        if image_data:
+            # ntfy supports image attachments via multipart form-data
+            headers["Filename"] = "photo.jpg"
+            headers["Message"] = message
+            response = await client.put(url, content=image_data, headers=headers)
+        else:
+            response = await client.post(url, content=message, headers=headers)
 
 
         if response.status_code in (200, 204):
         if response.status_code in (200, 204):
             return True, "Message sent successfully"
             return True, "Message sent successfully"
@@ -257,7 +266,7 @@ class NotificationService:
             except Exception:
             except Exception:
                 return False, f"HTTP {response.status_code}: {response.text[:200]}"
                 return False, f"HTTP {response.status_code}: {response.text[:200]}"
 
 
-    async def _send_telegram(self, config: dict, message: str) -> tuple[bool, str]:
+    async def _send_telegram(self, config: dict, message: str, image_data: bytes | None = None) -> tuple[bool, str]:
         """Send notification via Telegram bot."""
         """Send notification via Telegram bot."""
         bot_token = config.get("bot_token", "").strip()
         bot_token = config.get("bot_token", "").strip()
         chat_id = config.get("chat_id", "").strip()
         chat_id = config.get("chat_id", "").strip()
@@ -265,8 +274,6 @@ class NotificationService:
         if not bot_token or not chat_id:
         if not bot_token or not chat_id:
             return False, "Bot token and chat ID are required"
             return False, "Bot token and chat ID are required"
 
 
-        url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
-
         # Escape underscores in the message body so Telegram Markdown
         # Escape underscores in the message body so Telegram Markdown
         # parsing doesn't break on job names like "A1_plate_8" or error
         # parsing doesn't break on job names like "A1_plate_8" or error
         # codes like "0300_0001".  The title is already wrapped in *bold*
         # codes like "0300_0001".  The title is already wrapped in *bold*
@@ -276,14 +283,24 @@ class NotificationService:
             body_part = body_part.replace("_", "\\_")
             body_part = body_part.replace("_", "\\_")
             message = f"{title_part}\n{body_part}"
             message = f"{title_part}\n{body_part}"
 
 
-        data = {
-            "chat_id": chat_id,
-            "text": message,
-            "parse_mode": "Markdown",
-        }
-
         client = await self._get_client()
         client = await self._get_client()
-        response = await client.post(url, json=data)
+
+        if image_data:
+            # Use sendPhoto to attach the thumbnail with the caption
+            url = f"https://api.telegram.org/bot{bot_token}/sendPhoto"
+            response = await client.post(
+                url,
+                data={"chat_id": chat_id, "caption": message, "parse_mode": "Markdown"},
+                files={"photo": ("photo.jpg", image_data, "image/jpeg")},
+            )
+        else:
+            url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
+            data = {
+                "chat_id": chat_id,
+                "text": message,
+                "parse_mode": "Markdown",
+            }
+            response = await client.post(url, json=data)
 
 
         if response.status_code == 200:
         if response.status_code == 200:
             result = response.json()
             result = response.json()
@@ -451,11 +468,11 @@ class NotificationService:
             if provider.provider_type == "callmebot":
             if provider.provider_type == "callmebot":
                 return await self._send_callmebot(config, f"{title}\n{message}")
                 return await self._send_callmebot(config, f"{title}\n{message}")
             elif provider.provider_type == "ntfy":
             elif provider.provider_type == "ntfy":
-                return await self._send_ntfy(config, title, message)
+                return await self._send_ntfy(config, title, message, image_data=image_data)
             elif provider.provider_type == "pushover":
             elif provider.provider_type == "pushover":
                 return await self._send_pushover(config, title, message, image_data=image_data)
                 return await self._send_pushover(config, title, message, image_data=image_data)
             elif provider.provider_type == "telegram":
             elif provider.provider_type == "telegram":
-                return await self._send_telegram(config, f"*{title}*\n{message}")
+                return await self._send_telegram(config, f"*{title}*\n{message}", image_data=image_data)
             elif provider.provider_type == "email":
             elif provider.provider_type == "email":
                 return await self._send_email(config, title, message)
                 return await self._send_email(config, title, message)
             elif provider.provider_type == "discord":
             elif provider.provider_type == "discord":

+ 51 - 17
backend/app/utils/threemf_tools.py

@@ -320,7 +320,7 @@ def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | Non
         return None
         return None
 
 
 
 
-def extract_filament_usage_from_3mf(file_path: Path) -> list[dict]:
+def extract_filament_usage_from_3mf(file_path: Path, plate_id: int | None = None) -> list[dict]:
     """Extract per-filament total usage from 3MF slice_info.config.
     """Extract per-filament total usage from 3MF slice_info.config.
 
 
     This extracts the slicer-estimated total usage per filament slot,
     This extracts the slicer-estimated total usage per filament slot,
@@ -328,6 +328,7 @@ def extract_filament_usage_from_3mf(file_path: Path) -> list[dict]:
 
 
     Args:
     Args:
         file_path: Path to the 3MF file
         file_path: Path to the 3MF file
+        plate_id: Optional plate index to filter for (for multi-plate files)
 
 
     Returns:
     Returns:
         List of filament usage dictionaries:
         List of filament usage dictionaries:
@@ -342,22 +343,55 @@ def extract_filament_usage_from_3mf(file_path: Path) -> list[dict]:
             content = zf.read("Metadata/slice_info.config").decode()
             content = zf.read("Metadata/slice_info.config").decode()
             root = ET.fromstring(content)
             root = ET.fromstring(content)
 
 
-            for f in root.findall(".//filament"):
-                filament_id = f.get("id")
-                used_g = f.get("used_g", "0")
-                try:
-                    used_amount = float(used_g)
-                    if filament_id:
-                        filament_usage.append(
-                            {
-                                "slot_id": int(filament_id),
-                                "used_g": used_amount,
-                                "type": f.get("type", ""),
-                                "color": f.get("color", ""),
-                            }
-                        )
-                except (ValueError, TypeError):
-                    pass  # Skip filament entries with unparseable usage values
+            if plate_id is not None:
+                # Find the plate element with matching index
+                for plate_elem in root.findall(".//plate"):
+                    plate_index = None
+                    for meta in plate_elem.findall("metadata"):
+                        if meta.get("key") == "index":
+                            try:
+                                plate_index = int(meta.get("value", "0"))
+                            except ValueError:
+                                pass
+                            break
+
+                    if plate_index == plate_id:
+                        for f in plate_elem.findall("filament"):
+                            filament_id = f.get("id")
+                            used_g = f.get("used_g", "0")
+                            try:
+                                used_amount = float(used_g)
+                                if filament_id:
+                                    filament_usage.append(
+                                        {
+                                            "slot_id": int(filament_id),
+                                            "used_g": used_amount,
+                                            "type": f.get("type", ""),
+                                            "color": f.get("color", ""),
+                                        }
+                                    )
+                            except (ValueError, TypeError):
+                                pass
+                        break
+            else:
+                # No plate_id specified - extract all filaments
+                for f in root.findall(".//filament"):
+                    filament_id = f.get("id")
+                    used_g = f.get("used_g", "0")
+                    try:
+                        used_amount = float(used_g)
+                        if filament_id:
+                            filament_usage.append(
+                                {
+                                    "slot_id": int(filament_id),
+                                    "used_g": used_amount,
+                                    "type": f.get("type", ""),
+                                    "color": f.get("color", ""),
+                                }
+                            )
+                    except (ValueError, TypeError):
+                        pass  # Skip filament entries with unparseable usage values
+
     except Exception:
     except Exception:
         pass  # Return whatever usage data was collected before the error
         pass  # Return whatever usage data was collected before the error
 
 

+ 161 - 0
backend/tests/unit/test_threemf_tools.py

@@ -4,15 +4,27 @@ Tests G-code parsing, filament length-to-weight conversion,
 and cumulative layer usage lookup.
 and cumulative layer usage lookup.
 """
 """
 
 
+import io
 import math
 import math
+import zipfile
 
 
 from backend.app.utils.threemf_tools import (
 from backend.app.utils.threemf_tools import (
+    extract_filament_usage_from_3mf,
     get_cumulative_usage_at_layer,
     get_cumulative_usage_at_layer,
     mm_to_grams,
     mm_to_grams,
     parse_gcode_layer_filament_usage,
     parse_gcode_layer_filament_usage,
 )
 )
 
 
 
 
+def create_mock_3mf(slice_info_content: str) -> io.BytesIO:
+    """Create a mock 3MF file (ZIP) with slice_info.config content."""
+    buffer = io.BytesIO()
+    with zipfile.ZipFile(buffer, "w") as zf:
+        zf.writestr("Metadata/slice_info.config", slice_info_content)
+    buffer.seek(0)
+    return buffer
+
+
 class TestParseGcodeLayerFilamentUsage:
 class TestParseGcodeLayerFilamentUsage:
     """Tests for parse_gcode_layer_filament_usage()."""
     """Tests for parse_gcode_layer_filament_usage()."""
 
 
@@ -247,3 +259,152 @@ class TestGetCumulativeUsageAtLayer:
         """Target layer 0."""
         """Target layer 0."""
         data = {0: {0: 10.0}, 1: {0: 20.0}}
         data = {0: {0: 10.0}, 1: {0: 20.0}}
         assert get_cumulative_usage_at_layer(data, 0) == {0: 10.0}
         assert get_cumulative_usage_at_layer(data, 0) == {0: 10.0}
+
+
+class TestExtractFilamentUsageFrom3mf:
+    """Tests for extract_filament_usage_from_3mf function."""
+
+    def test_extract_single_filament(self, tmp_path):
+        """Test extracting a single filament."""
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <filament id="1" used_g="50.5" type="PLA" color="#FF0000"/>
+        </config>
+        """
+        mock_3mf = create_mock_3mf(xml_content)
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(mock_3mf.read())
+
+        result = extract_filament_usage_from_3mf(file_path)
+
+        assert len(result) == 1
+        assert result[0]["slot_id"] == 1
+        assert result[0]["used_g"] == 50.5
+        assert result[0]["type"] == "PLA"
+        assert result[0]["color"] == "#FF0000"
+
+    def test_extract_multiple_filaments(self, tmp_path):
+        """Test extracting multiple filaments."""
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <filament id="1" used_g="50.5" type="PLA" color="#FF0000"/>
+            <filament id="2" used_g="30.2" type="PETG" color="#00FF00"/>
+            <filament id="3" used_g="10.0" type="ABS" color="#0000FF"/>
+        </config>
+        """
+        mock_3mf = create_mock_3mf(xml_content)
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(mock_3mf.read())
+
+        result = extract_filament_usage_from_3mf(file_path)
+
+        assert len(result) == 3
+        assert result[0]["slot_id"] == 1
+        assert result[1]["slot_id"] == 2
+        assert result[2]["slot_id"] == 3
+
+    def test_extract_filament_with_plate_id(self, tmp_path):
+        """Test extracting filament for a specific plate."""
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="index" value="1"/>
+                <filament id="1" used_g="25.0" type="PLA" color="#FF0000"/>
+            </plate>
+            <plate>
+                <metadata key="index" value="2"/>
+                <filament id="1" used_g="75.0" type="PETG" color="#00FF00"/>
+            </plate>
+        </config>
+        """
+        mock_3mf = create_mock_3mf(xml_content)
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(mock_3mf.read())
+
+        result = extract_filament_usage_from_3mf(file_path, plate_id=2)
+
+        assert len(result) == 1
+        assert result[0]["used_g"] == 75.0
+        assert result[0]["type"] == "PETG"
+
+    def test_missing_slice_info_returns_empty(self, tmp_path):
+        """Test that missing slice_info.config returns empty list."""
+        buffer = io.BytesIO()
+        with zipfile.ZipFile(buffer, "w") as zf:
+            zf.writestr("other_file.txt", "content")
+        buffer.seek(0)
+
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(buffer.read())
+
+        result = extract_filament_usage_from_3mf(file_path)
+
+        assert result == []
+
+    def test_invalid_file_returns_empty(self, tmp_path):
+        """Test that invalid file returns empty list."""
+        file_path = tmp_path / "invalid.3mf"
+        file_path.write_text("not a zip file")
+
+        result = extract_filament_usage_from_3mf(file_path)
+
+        assert result == []
+
+    def test_nonexistent_file_returns_empty(self, tmp_path):
+        """Test that nonexistent file returns empty list."""
+        file_path = tmp_path / "nonexistent.3mf"
+
+        result = extract_filament_usage_from_3mf(file_path)
+
+        assert result == []
+
+    def test_filament_without_id_is_skipped(self, tmp_path):
+        """Test that filament without id is skipped."""
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <filament used_g="50.5" type="PLA" color="#FF0000"/>
+            <filament id="2" used_g="30.0" type="PETG" color="#00FF00"/>
+        </config>
+        """
+        mock_3mf = create_mock_3mf(xml_content)
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(mock_3mf.read())
+
+        result = extract_filament_usage_from_3mf(file_path)
+
+        assert len(result) == 1
+        assert result[0]["slot_id"] == 2
+
+    def test_invalid_used_g_is_skipped(self, tmp_path):
+        """Test that filament with invalid used_g is skipped."""
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <filament id="1" used_g="invalid" type="PLA" color="#FF0000"/>
+            <filament id="2" used_g="30.0" type="PETG" color="#00FF00"/>
+        </config>
+        """
+        mock_3mf = create_mock_3mf(xml_content)
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(mock_3mf.read())
+
+        result = extract_filament_usage_from_3mf(file_path)
+
+        assert len(result) == 1
+        assert result[0]["slot_id"] == 2
+
+    def test_missing_optional_fields(self, tmp_path):
+        """Test that missing type and color default to empty string."""
+        xml_content = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <filament id="1" used_g="50.5"/>
+        </config>
+        """
+        mock_3mf = create_mock_3mf(xml_content)
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(mock_3mf.read())
+
+        result = extract_filament_usage_from_3mf(file_path)
+
+        assert len(result) == 1
+        assert result[0]["type"] == ""
+        assert result[0]["color"] == ""

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

@@ -1236,6 +1236,7 @@ export interface PrintQueueItem {
   library_file_thumbnail?: string | null;
   library_file_thumbnail?: string | null;
   printer_name?: string | null;
   printer_name?: string | null;
   print_time_seconds?: number | null;  // Estimated print time from archive or library file
   print_time_seconds?: number | null;  // Estimated print time from archive or library file
+  filament_used_grams?: number | null;  // Estimated print weight from archive or library file
   // User tracking (Issue #206)
   // User tracking (Issue #206)
   created_by_id?: number | null;
   created_by_id?: number | null;
   created_by_username?: string | null;
   created_by_username?: string | null;

+ 5 - 5
frontend/src/components/PrintModal/index.tsx

@@ -251,9 +251,9 @@ export function PrintModal({
     setPerPrinterConfigs
     setPerPrinterConfigs
   );
   );
 
 
-  // Auto-select first plate for single-plate files
+  // Auto-select first plate when plates load (single or multi-plate)
   useEffect(() => {
   useEffect(() => {
-    if (platesData?.plates?.length === 1 && !selectedPlate) {
+    if (platesData?.plates && platesData.plates.length >= 1 && !selectedPlate) {
       setSelectedPlate(platesData.plates[0].index);
       setSelectedPlate(platesData.plates[0].index);
     }
     }
   }, [platesData, selectedPlate]);
   }, [platesData, selectedPlate]);
@@ -528,11 +528,11 @@ export function PrintModal({
     // Model-based assignment only works in queue modes (not immediate reprint)
     // Model-based assignment only works in queue modes (not immediate reprint)
     if (assignmentMode === 'model' && mode === 'reprint') return false;
     if (assignmentMode === 'model' && mode === 'reprint') return false;
 
 
-    // For multi-plate archive files, need a selected plate (library files skip this)
-    if (!isLibraryFile && isMultiPlate && !selectedPlate) return false;
+    // For multi-plate files, need a selected plate
+    if (isMultiPlate && !selectedPlate) return false;
 
 
     return true;
     return true;
-  }, [selectedPrinters.length, assignmentMode, targetModel, mode, isMultiPlate, selectedPlate, isPending, isLibraryFile]);
+  }, [selectedPrinters.length, assignmentMode, targetModel, mode, isMultiPlate, selectedPlate, isPending]);
 
 
   // Modal title and action button text based on mode
   // Modal title and action button text based on mode
   const getModalConfig = () => {
   const getModalConfig = () => {

+ 99 - 3
frontend/src/components/SkipObjectsModal.tsx

@@ -1,7 +1,7 @@
 import { useState } from 'react';
 import { useState } from 'react';
 import { useQuery, useMutation } from '@tanstack/react-query';
 import { useQuery, useMutation } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { X, Loader2, Monitor, AlertCircle, Box } from 'lucide-react';
+import { X, Loader2, Monitor, AlertCircle, Box, Maximize2 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
@@ -31,6 +31,7 @@ export function SkipObjectsModal({ printerId, isOpen, onClose }: SkipObjectsModa
   const { showToast } = useToast();
   const { showToast } = useToast();
   const { hasPermission } = useAuth();
   const { hasPermission } = useAuth();
   const [pendingSkip, setPendingSkip] = useState<{ id: number; name: string } | null>(null);
   const [pendingSkip, setPendingSkip] = useState<{ id: number; name: string } | null>(null);
+  const [enlarged, setEnlarged] = useState(false);
 
 
   const { data: status } = useQuery({
   const { data: status } = useQuery({
     queryKey: ['printerStatus', printerId],
     queryKey: ['printerStatus', printerId],
@@ -63,7 +64,12 @@ export function SkipObjectsModal({ printerId, isOpen, onClose }: SkipObjectsModa
     <div
     <div
       className="fixed inset-0 z-50 flex items-center justify-center"
       className="fixed inset-0 z-50 flex items-center justify-center"
       onClick={onClose}
       onClick={onClose}
-      onKeyDown={(e) => e.key === 'Escape' && onClose()}
+      onKeyDown={(e) => {
+        if (e.key === 'Escape') {
+          if (enlarged) setEnlarged(false);
+          else onClose();
+        }
+      }}
       tabIndex={-1}
       tabIndex={-1}
       ref={(el) => el?.focus()}
       ref={(el) => el?.focus()}
     >
     >
@@ -127,7 +133,7 @@ export function SkipObjectsModal({ printerId, isOpen, onClose }: SkipObjectsModa
             <div className="flex flex-1 overflow-hidden">
             <div className="flex flex-1 overflow-hidden">
               {/* Left: Preview Image with object markers */}
               {/* Left: Preview Image with object markers */}
               <div className="w-52 flex-shrink-0 p-4 border-r border-gray-200 dark:border-bambu-dark-tertiary bg-gray-50 dark:bg-bambu-dark-secondary overflow-y-auto">
               <div className="w-52 flex-shrink-0 p-4 border-r border-gray-200 dark:border-bambu-dark-tertiary bg-gray-50 dark:bg-bambu-dark-secondary overflow-y-auto">
-                <div className="relative">
+                <div className="relative cursor-pointer group" onClick={() => setEnlarged(true)}>
                   {status?.cover_url ? (
                   {status?.cover_url ? (
                     <img
                     <img
                       src={`${status.cover_url}?view=top`}
                       src={`${status.cover_url}?view=top`}
@@ -139,6 +145,10 @@ export function SkipObjectsModal({ printerId, isOpen, onClose }: SkipObjectsModa
                       <Box className="w-8 h-8 text-gray-300 dark:text-bambu-gray/30" />
                       <Box className="w-8 h-8 text-gray-300 dark:text-bambu-gray/30" />
                     </div>
                     </div>
                   )}
                   )}
+                  {/* Enlarge hint */}
+                  <div className="absolute top-2 right-2 p-1 bg-black/60 rounded opacity-0 group-hover:opacity-100 transition-opacity">
+                    <Maximize2 className="w-3.5 h-3.5 text-white" />
+                  </div>
                   {/* Object ID markers overlay - positioned based on object data */}
                   {/* Object ID markers overlay - positioned based on object data */}
                   {objectsData.objects.length > 0 && (
                   {objectsData.objects.length > 0 && (
                     <div className="absolute inset-0 pointer-events-none">
                     <div className="absolute inset-0 pointer-events-none">
@@ -283,6 +293,92 @@ export function SkipObjectsModal({ printerId, isOpen, onClose }: SkipObjectsModa
         onCancel={() => setPendingSkip(null)}
         onCancel={() => setPendingSkip(null)}
       />
       />
     )}
     )}
+    {/* Enlarged lightbox overlay */}
+    {enlarged && objectsData && (
+      <div
+        className="fixed inset-0 bg-black/90 flex items-center justify-center z-60"
+        onClick={() => setEnlarged(false)}
+      >
+        <button
+          onClick={() => setEnlarged(false)}
+          className="absolute top-4 right-4 p-2 text-white/70 hover:text-white transition-colors"
+        >
+          <X className="w-6 h-6" />
+        </button>
+        <div
+          className="relative max-w-[600px] max-h-[80vh] aspect-square"
+          onClick={(e) => e.stopPropagation()}
+        >
+          {status?.cover_url ? (
+            <img
+              src={`${status.cover_url}?view=top`}
+              alt={t('printers.printPreview')}
+              className="w-full h-full object-contain rounded-lg bg-gray-900"
+            />
+          ) : (
+            <div className="w-full h-full rounded-lg bg-gray-800 flex items-center justify-center">
+              <Box className="w-16 h-16 text-gray-500" />
+            </div>
+          )}
+          {/* Object ID markers overlay */}
+          {objectsData.objects.length > 0 && (
+            <div className="absolute inset-0 pointer-events-none">
+              {objectsData.objects.map((obj, idx) => {
+                let x: number, y: number;
+
+                if (obj.x != null && obj.y != null && objectsData.bbox_all) {
+                  const [xMin, yMin, xMax, yMax] = objectsData.bbox_all;
+                  const bboxWidth = xMax - xMin;
+                  const bboxHeight = yMax - yMin;
+                  const padding = 8;
+                  const contentArea = 100 - (padding * 2);
+                  x = padding + ((obj.x - xMin) / bboxWidth) * contentArea;
+                  y = padding + ((yMax - obj.y) / bboxHeight) * contentArea;
+                  x = Math.max(5, Math.min(95, x));
+                  y = Math.max(5, Math.min(95, y));
+                } else if (obj.x != null && obj.y != null) {
+                  const buildPlate = 256;
+                  x = (obj.x / buildPlate) * 100;
+                  y = 100 - (obj.y / buildPlate) * 100;
+                  x = Math.max(5, Math.min(95, x));
+                  y = Math.max(5, Math.min(95, y));
+                } else {
+                  const cols = Math.ceil(Math.sqrt(objectsData.objects.length));
+                  const row = Math.floor(idx / cols);
+                  const col = idx % cols;
+                  const rows = Math.ceil(objectsData.objects.length / cols);
+                  x = 15 + (col * (70 / cols)) + (35 / cols);
+                  y = 15 + (row * (70 / rows)) + (35 / rows);
+                }
+
+                return (
+                  <div
+                    key={obj.id}
+                    className={`absolute flex items-center justify-center w-6 h-6 rounded-full text-[10px] font-bold shadow-lg ${
+                      obj.skipped
+                        ? 'bg-red-500 text-white line-through'
+                        : 'bg-bambu-green text-black'
+                    }`}
+                    style={{
+                      left: `${x}%`,
+                      top: `${y}%`,
+                      transform: 'translate(-50%, -50%)'
+                    }}
+                    title={obj.name}
+                  >
+                    {obj.id}
+                  </div>
+                );
+              })}
+            </div>
+          )}
+          {/* Active count badge */}
+          <div className="absolute bottom-2 right-2 px-2 py-1 bg-white/90 dark:bg-black/80 rounded text-[10px] text-gray-700 dark:text-white shadow-sm">
+            {t('printers.skipObjects.activeCount', { count: objectsData.objects.filter(o => !o.skipped).length })}
+          </div>
+        </div>
+      </div>
+    )}
   </>
   </>
   );
   );
 }
 }

+ 1 - 1
frontend/src/components/SpoolFormModal.tsx

@@ -411,7 +411,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
         </div>
         </div>
 
 
         {/* Content */}
         {/* Content */}
-        <div className="p-4 overflow-y-auto flex-1">
+        <div className="p-4 overflow-y-auto flex-1" style={{ scrollbarGutter: 'stable' }}>
           {activeTab === 'filament' ? (
           {activeTab === 'filament' ? (
             <div className="space-y-6">
             <div className="space-y-6">
               {/* Filament Info Section */}
               {/* Filament Info Section */}

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -762,6 +762,7 @@ export default {
       printing: 'Druckt',
       printing: 'Druckt',
       queued: 'In Warteschlange',
       queued: 'In Warteschlange',
       totalTime: 'Gesamte Wartezeit',
       totalTime: 'Gesamte Wartezeit',
+      totalWeight: 'Gesamtgewicht der Warteschlange',
       history: 'Verlauf',
       history: 'Verlauf',
     },
     },
     // Filters
     // Filters

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -762,6 +762,7 @@ export default {
       printing: 'Printing',
       printing: 'Printing',
       queued: 'Queued',
       queued: 'Queued',
       totalTime: 'Total Queue Time',
       totalTime: 'Total Queue Time',
+      totalWeight: 'Total Queue Weight',
       history: 'History',
       history: 'History',
     },
     },
     // Filters
     // Filters

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -762,6 +762,7 @@ export default {
       printing: 'Impressions',
       printing: 'Impressions',
       queued: 'En attente',
       queued: 'En attente',
       totalTime: 'Temps total estimé',
       totalTime: 'Temps total estimé',
+      totalWeight: 'Poids total estimé',
       history: 'Historique',
       history: 'Historique',
     },
     },
     // Filters
     // Filters

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -749,6 +749,7 @@ export default {
       printing: 'In stampa',
       printing: 'In stampa',
       queued: 'In coda',
       queued: 'In coda',
       totalTime: 'Tempo totale coda',
       totalTime: 'Tempo totale coda',
+      totalWeight: 'Peso totale della coda',
       history: 'Cronologia',
       history: 'Cronologia',
     },
     },
     // Filters
     // Filters

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -830,6 +830,7 @@ export default {
       queued: 'キュー中',
       queued: 'キュー中',
       history: '履歴',
       history: '履歴',
       totalTime: 'キュー合計時間',
       totalTime: 'キュー合計時間',
+      totalWeight: 'キュー合計重量',
     },
     },
     filter: {
     filter: {
       allPrinters: 'すべてのプリンター',
       allPrinters: 'すべてのプリンター',

+ 32 - 1
frontend/src/pages/QueuePage.tsx

@@ -47,6 +47,7 @@ import {
   Square,
   Square,
   User,
   User,
   Pause,
   Pause,
+  Weight,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
 import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
@@ -66,6 +67,11 @@ function formatDuration(seconds: number | null | undefined): string {
   return `${minutes}m`;
   return `${minutes}m`;
 }
 }
 
 
+function formatWeight(g: number, useKg = false): string {
+  if (useKg && g >= 1000) return `${(g / 1000).toFixed(1)}kg`;
+  return `${Math.round(g)}g`;
+}
+
 function formatRelativeTime(dateString: string | null, timeFormat: TimeFormat = 'system', t?: (key: string, options?: Record<string, unknown>) => string): string {
 function formatRelativeTime(dateString: string | null, timeFormat: TimeFormat = 'system', t?: (key: string, options?: Record<string, unknown>) => string): string {
   if (!dateString) return t?.('queue.time.asap') ?? 'ASAP';
   if (!dateString) return t?.('queue.time.asap') ?? 'ASAP';
   const date = parseUTCDate(dateString);
   const date = parseUTCDate(dateString);
@@ -450,6 +456,12 @@ function SortableQueueItem({
                 {formatDuration(item.print_time_seconds)}
                 {formatDuration(item.print_time_seconds)}
               </span>
               </span>
             )}
             )}
+            {item.filament_used_grams && (
+              <span className="flex items-center gap-1.5">
+                <Weight className="w-3.5 h-3.5" />
+                {formatWeight(item.filament_used_grams)}
+              </span>
+            )}
             {item.created_by_username && (
             {item.created_by_username && (
               <span className="flex items-center gap-1.5" title={t('queue.addedBy', { name: item.created_by_username })}>
               <span className="flex items-center gap-1.5" title={t('queue.addedBy', { name: item.created_by_username })}>
                 <User className="w-3.5 h-3.5" />
                 <User className="w-3.5 h-3.5" />
@@ -894,6 +906,11 @@ export function QueuePage() {
     return pendingItems.reduce((acc, item) => acc + (item.print_time_seconds || 0), 0);
     return pendingItems.reduce((acc, item) => acc + (item.print_time_seconds || 0), 0);
   }, [pendingItems]);
   }, [pendingItems]);
 
 
+  // Calculate total material weight
+  const totalWeight = useMemo(() => {
+    return pendingItems.reduce((acc, item) => acc + (item.filament_used_grams || 0), 0);
+  }, [pendingItems]);
+
   const handleDragEnd = (event: DragEndEvent) => {
   const handleDragEnd = (event: DragEndEvent) => {
     const { active, over } = event;
     const { active, over } = event;
     if (!over || active.id === over.id) return;
     if (!over || active.id === over.id) return;
@@ -925,7 +942,7 @@ export function QueuePage() {
       </div>
       </div>
 
 
       {/* Summary Cards */}
       {/* Summary Cards */}
-      <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
+      <div className="grid grid-cols-1 md:grid-cols-5 gap-4 mb-8">
         <Card className="bg-gradient-to-br from-blue-500/10 to-transparent border-blue-500/20">
         <Card className="bg-gradient-to-br from-blue-500/10 to-transparent border-blue-500/20">
           <CardContent className="p-4">
           <CardContent className="p-4">
             <div className="flex items-center gap-3">
             <div className="flex items-center gap-3">
@@ -968,6 +985,20 @@ export function QueuePage() {
           </CardContent>
           </CardContent>
         </Card>
         </Card>
 
 
+        <Card className="bg-gradient-to-br from-purple-500/10 to-transparent border-purple-500/20">
+          <CardContent className="p-4">
+            <div className="flex items-center gap-3">
+              <div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
+                <Weight className="w-5 h-5 text-purple-500" />
+              </div>
+              <div>
+                <p className="text-2xl font-bold text-white">{formatWeight(totalWeight)}</p>
+                <p className="text-sm text-bambu-gray">{t('queue.summary.totalWeight')}</p>
+              </div>
+            </div>
+          </CardContent>
+        </Card>
+
         <Card className="bg-gradient-to-br from-gray-500/10 to-transparent border-gray-500/20">
         <Card className="bg-gradient-to-br from-gray-500/10 to-transparent border-gray-500/20">
           <CardContent className="p-4">
           <CardContent className="p-4">
             <div className="flex items-center gap-3">
             <div className="flex items-center gap-3">

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-BstMPBCa.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-D7b3EUDG.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-VlqasY_r.css


+ 2 - 2
static/index.html

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

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