Bladeren bron

Merge branch '0.2.0b' into feature/mobile_view_fix

Keybored 3 maanden geleden
bovenliggende
commit
9886cb2007

+ 3 - 0
CHANGELOG.md

@@ -15,8 +15,10 @@ All notable changes to Bambuddy will be documented in this file.
 - **Dual External Spool Support for H2D** — H2-series printers with two external spool holders (Ext-L and Ext-R) are now fully supported. The external spool section renders as a grid with both slots, each showing filament type, color, fill level, and hover card details. Previously only a single external spool was displayed. Applies to the printer card, filament mapping, print scheduler, usage tracking, and inventory assignment. The `vt_tray` field is now an array across the entire stack (MQTT, API, WebSocket, frontend).
 - **Dual External Spool Support for H2D** — H2-series printers with two external spool holders (Ext-L and Ext-R) are now fully supported. The external spool section renders as a grid with both slots, each showing filament type, color, fill level, and hover card details. Previously only a single external spool was displayed. Applies to the printer card, filament mapping, print scheduler, usage tracking, and inventory assignment. The `vt_tray` field is now an array across the entire stack (MQTT, API, WebSocket, frontend).
 - **AMS Slot Configuration — Model Filtering & Pre-Population** — The Configure AMS Slot modal now filters filament presets by the connected printer model. Only presets matching the printer (e.g., "@BBL X1C" presets for X1C printers) and generic presets without a model suffix are shown. Local presets are filtered by their `compatible_printers` field. When re-configuring an already-configured slot, the modal pre-selects the saved preset, pre-populates the color, and auto-selects the active K-profile. The preset list auto-scrolls to the selected item. All modal strings are now fully translated in 5 locales (en, de, fr, it, ja).
 - **AMS Slot Configuration — Model Filtering & Pre-Population** — The Configure AMS Slot modal now filters filament presets by the connected printer model. Only presets matching the printer (e.g., "@BBL X1C" presets for X1C printers) and generic presets without a model suffix are shown. Local presets are filtered by their `compatible_printers` field. When re-configuring an already-configured slot, the modal pre-selects the saved preset, pre-populates the color, and auto-selects the active K-profile. The preset list auto-scrolls to the selected item. All modal strings are now fully translated in 5 locales (en, de, fr, it, ja).
 - **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.
 
 
 ### 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.
 - **Update Check Runs When Disabled** ([#367](https://github.com/maziggy/bambuddy/issues/367)) — The Settings page triggered an update check on every visit even when "Check for updates" was disabled, causing error popups on air-gapped systems with no internet. The backend `/updates/check` endpoint also ignored the setting entirely. Now the backend returns early without making GitHub API calls when the setting is disabled, the Settings page respects the `check_updates` flag before auto-fetching, and the printer card firmware badge shows a neutral version-only display instead of disappearing when firmware update checks are off.
 - **Update Check Runs When Disabled** ([#367](https://github.com/maziggy/bambuddy/issues/367)) — The Settings page triggered an update check on every visit even when "Check for updates" was disabled, causing error popups on air-gapped systems with no internet. The backend `/updates/check` endpoint also ignored the setting entirely. Now the backend returns early without making GitHub API calls when the setting is disabled, the Settings page respects the `check_updates` flag before auto-fetching, and the printer card firmware badge shows a neutral version-only display instead of disappearing when firmware update checks are off.
 - **Stale Inventory Assignments Persist After Switching to Spoolman Mode** — When switching from built-in inventory to Spoolman mode, existing spool-to-AMS-slot assignments were not cleaned up. The printer card hover cards continued showing "Assign Spool" buttons that opened the internal inventory modal, and any prior assignments remained visible. Now bulk-deletes all `SpoolAssignment` records when enabling Spoolman, invalidates the frontend cache so printer cards update immediately, and hides the inventory assign/unassign UI on printer cards while in Spoolman mode.
 - **Stale Inventory Assignments Persist After Switching to Spoolman Mode** — When switching from built-in inventory to Spoolman mode, existing spool-to-AMS-slot assignments were not cleaned up. The printer card hover cards continued showing "Assign Spool" buttons that opened the internal inventory modal, and any prior assignments remained visible. Now bulk-deletes all `SpoolAssignment` records when enabling Spoolman, invalidates the frontend cache so printer cards update immediately, and hides the inventory assign/unassign UI on printer cards while in Spoolman mode.
 - **Bulk Archive Delete Leaves Orphaned Database Records** — When bulk-deleting archives, the files were removed from disk before the database commit. If concurrent SQLite writes caused a lock timeout, the commit failed and rolled back — leaving database records pointing to deleted files (broken thumbnails, 404 errors). Fixed by deleting the database record first and only removing files after a successful commit.
 - **Bulk Archive Delete Leaves Orphaned Database Records** — When bulk-deleting archives, the files were removed from disk before the database commit. If concurrent SQLite writes caused a lock timeout, the commit failed and rolled back — leaving database records pointing to deleted files (broken thumbnails, 404 errors). Fixed by deleting the database record first and only removing files after a successful commit.
@@ -32,6 +34,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Reprint Fails with SD Card Error for Archives Without 3MF File** ([#376](https://github.com/maziggy/bambuddy/issues/376)) — When a print was sent from an external slicer and Bambuddy couldn't download the 3MF from the printer during auto-archiving, the fallback archive had no file. Attempting to reprint such an archive tried to upload the data directory as a file, causing a confusing "SD card error." The backend now returns a clear error for file-less archives, and the frontend disables Print/Schedule/Open in Slicer buttons with a tooltip explaining that the 3MF file is unavailable.
 - **Reprint Fails with SD Card Error for Archives Without 3MF File** ([#376](https://github.com/maziggy/bambuddy/issues/376)) — When a print was sent from an external slicer and Bambuddy couldn't download the 3MF from the printer during auto-archiving, the fallback archive had no file. Attempting to reprint such an archive tried to upload the data directory as a file, causing a confusing "SD card error." The backend now returns a clear error for file-less archives, and the frontend disables Print/Schedule/Open in Slicer buttons with a tooltip explaining that the 3MF file is unavailable.
 - **Inventory Spool Weight Resets After Print Completes** — After a print, the usage tracker correctly updated `weight_used` (e.g., +1.6g), but periodic AMS status updates recalculated `weight_used` from the AMS remain% sensor and overwrote the precise value. For small prints on large spools (e.g., 1.6g on 1000g), the AMS remain% stays at 100% (integer resolution = 10g steps), resetting `weight_used` back to 0. The AMS weight sync now only increases `weight_used`, never decreases it, preserving precise values from the usage tracker.
 - **Inventory Spool Weight Resets After Print Completes** — After a print, the usage tracker correctly updated `weight_used` (e.g., +1.6g), but periodic AMS status updates recalculated `weight_used` from the AMS remain% sensor and overwrote the precise value. For small prints on large spools (e.g., 1.6g on 1000g), the AMS remain% stays at 100% (integer resolution = 10g steps), resetting `weight_used` back to 0. The AMS weight sync now only increases `weight_used`, never decreases it, preserving precise values from the usage tracker.
 - **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`).
+- **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.
 
 
 ### Improved
 ### Improved
 - **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.

+ 1 - 0
README.md

@@ -76,6 +76,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Plate thumbnail browsing for multi-plate archives (hover to navigate between plates)
 - Plate thumbnail browsing for multi-plate archives (hover to navigate between plates)
 - Archive comparison (side-by-side diff)
 - Archive comparison (side-by-side diff)
 - Tag management (rename/delete across all archives)
 - Tag management (rename/delete across all archives)
+- **Print Log** — Chronological table view of all print activity with columns for date/time, print name, printer, user, status, duration, and filament. Filterable by search, printer, user, status, and date range. Pagination with configurable page size. Clear button removes log entries without affecting archives.
 
 
 ### 📊 Monitoring & Control
 ### 📊 Monitoring & Control
 - Real-time printer status via WebSocket
 - Real-time printer status via WebSocket

+ 6 - 4
backend/app/api/routes/archives.py

@@ -134,13 +134,15 @@ async def list_archives(
         offset=offset,
         offset=offset,
     )
     )
 
 
-    # Get set of hashes that have duplicates (efficient single query)
-    duplicate_hashes = await service.get_duplicate_hashes()
+    # Get sets of hashes and names that have duplicates (efficient single queries)
+    duplicate_hashes, duplicate_names = await service.get_duplicate_hashes_and_names()
 
 
-    # Mark archives that have duplicates
+    # Mark archives that have duplicates (by hash or by print name)
     result = []
     result = []
     for a in archives:
     for a in archives:
-        has_duplicate = a.content_hash in duplicate_hashes if a.content_hash else False
+        has_hash_dup = a.content_hash in duplicate_hashes if a.content_hash else False
+        has_name_dup = a.print_name and a.print_name.lower() in duplicate_names
+        has_duplicate = has_hash_dup or has_name_dup
         result.append(archive_to_response(a, duplicate_count=1 if has_duplicate else 0))
         result.append(archive_to_response(a, duplicate_count=1 if has_duplicate else 0))
     return result
     return result
 
 

+ 129 - 0
backend/app/api/routes/print_log.py

@@ -0,0 +1,129 @@
+import logging
+from datetime import datetime
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi.responses import FileResponse
+from sqlalchemy import delete, func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.config import settings
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.print_log import PrintLogEntry
+from backend.app.models.user import User
+from backend.app.schemas.print_log import PrintLogEntrySchema, PrintLogResponse
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/print-log", tags=["print-log"])
+
+
+@router.get("/", response_model=PrintLogResponse)
+async def get_print_log(
+    search: str | None = None,
+    printer_id: int | None = None,
+    created_by_username: str | None = None,
+    status: str | None = None,
+    date_from: datetime | None = None,
+    date_to: datetime | None = None,
+    limit: int = Query(default=50, ge=1, le=500),
+    offset: int = Query(default=0, ge=0),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
+    """Get the print log."""
+    query = select(PrintLogEntry)
+    count_query = select(func.count(PrintLogEntry.id))
+
+    if printer_id is not None:
+        query = query.where(PrintLogEntry.printer_id == printer_id)
+        count_query = count_query.where(PrintLogEntry.printer_id == printer_id)
+    if created_by_username:
+        query = query.where(PrintLogEntry.created_by_username == created_by_username)
+        count_query = count_query.where(PrintLogEntry.created_by_username == created_by_username)
+    if status:
+        query = query.where(PrintLogEntry.status == status)
+        count_query = count_query.where(PrintLogEntry.status == status)
+    if search:
+        query = query.where(PrintLogEntry.print_name.ilike(f"%{search}%"))
+        count_query = count_query.where(PrintLogEntry.print_name.ilike(f"%{search}%"))
+    if date_from:
+        query = query.where(PrintLogEntry.created_at >= date_from)
+        count_query = count_query.where(PrintLogEntry.created_at >= date_from)
+    if date_to:
+        query = query.where(PrintLogEntry.created_at <= date_to)
+        count_query = count_query.where(PrintLogEntry.created_at <= date_to)
+
+    # Get total count
+    total_result = await db.execute(count_query)
+    total = total_result.scalar() or 0
+
+    # Get paginated results
+    query = query.order_by(PrintLogEntry.created_at.desc()).offset(offset).limit(limit)
+    result = await db.execute(query)
+    entries = result.scalars().all()
+
+    return PrintLogResponse(
+        items=[
+            PrintLogEntrySchema(
+                id=e.id,
+                print_name=e.print_name,
+                printer_name=e.printer_name,
+                printer_id=e.printer_id,
+                status=e.status,
+                started_at=e.started_at,
+                completed_at=e.completed_at,
+                duration_seconds=e.duration_seconds,
+                filament_type=e.filament_type,
+                filament_color=e.filament_color,
+                filament_used_grams=e.filament_used_grams,
+                thumbnail_path=e.thumbnail_path,
+                created_by_username=e.created_by_username,
+                created_at=e.created_at,
+            )
+            for e in entries
+        ],
+        total=total,
+    )
+
+
+@router.get("/{entry_id}/thumbnail")
+async def get_print_log_thumbnail(
+    entry_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get the thumbnail for a print log entry.
+
+    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    """
+    entry = await db.get(PrintLogEntry, entry_id)
+    if not entry or not entry.thumbnail_path:
+        raise HTTPException(404, "Thumbnail not found")
+
+    thumb_path = settings.base_dir / entry.thumbnail_path
+    if not thumb_path.exists():
+        raise HTTPException(404, "Thumbnail file not found")
+
+    return FileResponse(
+        path=thumb_path,
+        media_type="image/png",
+        headers={"Cache-Control": "public, max-age=86400"},
+    )
+
+
+@router.delete("/")
+async def clear_print_log(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_ALL),
+):
+    """Clear the print log.
+
+    Only deletes log entries. Archives and queue items are never touched.
+    """
+    result = await db.execute(delete(PrintLogEntry))
+    deleted = result.rowcount
+    await db.commit()
+
+    logger.info("Print log cleared: %d entries deleted", deleted)
+    return {"deleted": deleted}

+ 1 - 0
backend/app/core/database.py

@@ -89,6 +89,7 @@ async def init_db():
         notification_template,
         notification_template,
         orca_base_cache,
         orca_base_cache,
         pending_upload,
         pending_upload,
+        print_log,
         print_queue,
         print_queue,
         printer,
         printer,
         project,
         project,

+ 35 - 0
backend/app/main.py

@@ -194,6 +194,7 @@ from backend.app.api.routes import (
     notification_templates,
     notification_templates,
     notifications,
     notifications,
     pending_uploads,
     pending_uploads,
+    print_log,
     print_queue,
     print_queue,
     printers,
     printers,
     projects,
     projects,
@@ -1958,6 +1959,9 @@ async def on_print_complete(printer_id: int, data: dict):
     except Exception as e:
     except Exception as e:
         logger.warning("[CALLBACK] WebSocket send_print_complete failed: %s", e)
         logger.warning("[CALLBACK] WebSocket send_print_complete failed: %s", e)
 
 
+    # Capture user info before clearing (needed for print log entry)
+    _print_user_info = printer_manager.get_current_print_user(printer_id)
+
     # Clear current print user tracking (Issue #206)
     # Clear current print user tracking (Issue #206)
     printer_manager.clear_current_print_user(printer_id)
     printer_manager.clear_current_print_user(printer_id)
 
 
@@ -2158,6 +2162,36 @@ async def on_print_complete(printer_id: int, data: dict):
 
 
     log_timing("Archive status update")
     log_timing("Archive status update")
 
 
+    # Write independent print log entry (separate table, never touches archives)
+    try:
+        async with async_session() as db:
+            from backend.app.models.archive import PrintArchive
+            from backend.app.services.print_log import write_log_entry
+
+            archive = await db.get(PrintArchive, archive_id)
+            if archive:
+                p_info = printer_manager.get_printer(printer_id)
+                await write_log_entry(
+                    db,
+                    status=data.get("status", "completed"),
+                    print_name=archive.print_name,
+                    printer_name=p_info.name if p_info else None,
+                    printer_id=printer_id,
+                    started_at=archive.started_at,
+                    completed_at=archive.completed_at,
+                    filament_type=archive.filament_type,
+                    filament_color=archive.filament_color,
+                    filament_used_grams=archive.filament_used_grams,
+                    thumbnail_path=archive.thumbnail_path,
+                    created_by_username=_print_user_info.get("username") if _print_user_info else None,
+                )
+                await db.commit()
+                logger.info("[PRINT_LOG] Log entry written for archive %s", archive_id)
+    except Exception as e:
+        logger.warning("[PRINT_LOG] Failed to write log entry for archive %s: %s", archive_id, e)
+
+    log_timing("Print log entry")
+
     # Track filament consumption from AMS remain% deltas (skip if Spoolman handles usage)
     # Track filament consumption from AMS remain% deltas (skip if Spoolman handles usage)
     usage_results: list[dict] = []
     usage_results: list[dict] = []
     try:
     try:
@@ -3369,6 +3403,7 @@ app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
 app.include_router(cloud.router, prefix=app_settings.api_prefix)
 app.include_router(cloud.router, prefix=app_settings.api_prefix)
 app.include_router(local_presets.router, prefix=app_settings.api_prefix)
 app.include_router(local_presets.router, prefix=app_settings.api_prefix)
 app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
 app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
+app.include_router(print_log.router, prefix=app_settings.api_prefix)
 app.include_router(print_queue.router, prefix=app_settings.api_prefix)
 app.include_router(print_queue.router, prefix=app_settings.api_prefix)
 app.include_router(kprofiles.router, prefix=app_settings.api_prefix)
 app.include_router(kprofiles.router, prefix=app_settings.api_prefix)
 app.include_router(notifications.router, prefix=app_settings.api_prefix)
 app.include_router(notifications.router, prefix=app_settings.api_prefix)

+ 31 - 0
backend/app/models/print_log.py

@@ -0,0 +1,31 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, Float, Integer, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class PrintLogEntry(Base):
+    """Independent print log entry. Written when print events occur.
+
+    This is a separate table from archives/queue — clearing the log
+    never touches archives or queue items.
+    """
+
+    __tablename__ = "print_log_entries"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    print_name: Mapped[str | None] = mapped_column(String(255))
+    printer_name: Mapped[str | None] = mapped_column(String(255))
+    printer_id: Mapped[int | None] = mapped_column(Integer)
+    status: Mapped[str] = mapped_column(String(20))  # completed, failed, stopped, cancelled, skipped
+    started_at: Mapped[datetime | None] = mapped_column(DateTime)
+    completed_at: Mapped[datetime | None] = mapped_column(DateTime)
+    duration_seconds: Mapped[int | None] = mapped_column(Integer)
+    filament_type: Mapped[str | None] = mapped_column(String(50))
+    filament_color: Mapped[str | None] = mapped_column(String(50))
+    filament_used_grams: Mapped[float | None] = mapped_column(Float)
+    thumbnail_path: Mapped[str | None] = mapped_column(String(500))
+    created_by_username: Mapped[str | None] = mapped_column(String(100))
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

+ 25 - 0
backend/app/schemas/print_log.py

@@ -0,0 +1,25 @@
+from datetime import datetime
+
+from pydantic import BaseModel
+
+
+class PrintLogEntrySchema(BaseModel):
+    id: int
+    print_name: str | None = None
+    printer_name: str | None = None
+    printer_id: int | None = None
+    status: str
+    started_at: datetime | None = None
+    completed_at: datetime | None = None
+    duration_seconds: int | None = None
+    filament_type: str | None = None
+    filament_color: str | None = None
+    filament_used_grams: float | None = None
+    thumbnail_path: str | None = None
+    created_by_username: str | None = None
+    created_at: datetime
+
+
+class PrintLogResponse(BaseModel):
+    items: list[PrintLogEntrySchema]
+    total: int

+ 14 - 4
backend/app/services/archive.py

@@ -727,10 +727,10 @@ class ArchiveService:
                 sha256.update(chunk)
                 sha256.update(chunk)
         return sha256.hexdigest()
         return sha256.hexdigest()
 
 
-    async def get_duplicate_hashes(self) -> set[str]:
-        """Get all content hashes that appear more than once.
+    async def get_duplicate_hashes_and_names(self) -> tuple[set[str], set[str]]:
+        """Get all content hashes and print names that appear more than once.
 
 
-        Returns a set of hashes that have duplicates.
+        Returns a tuple of (duplicate_hashes, duplicate_names).
         """
         """
         from sqlalchemy import func
         from sqlalchemy import func
 
 
@@ -740,7 +740,17 @@ class ArchiveService:
             .group_by(PrintArchive.content_hash)
             .group_by(PrintArchive.content_hash)
             .having(func.count(PrintArchive.id) > 1)
             .having(func.count(PrintArchive.id) > 1)
         )
         )
-        return {row[0] for row in result.all()}
+        duplicate_hashes = {row[0] for row in result.all()}
+
+        result = await self.db.execute(
+            select(func.lower(PrintArchive.print_name))
+            .where(PrintArchive.print_name.isnot(None))
+            .group_by(func.lower(PrintArchive.print_name))
+            .having(func.count(PrintArchive.id) > 1)
+        )
+        duplicate_names = {row[0] for row in result.all()}
+
+        return duplicate_hashes, duplicate_names
 
 
     async def find_duplicates(
     async def find_duplicates(
         self,
         self,

+ 10 - 26
backend/app/services/firmware_check.py

@@ -324,14 +324,6 @@ class FirmwareCheckService:
         cache_dir.mkdir(parents=True, exist_ok=True)
         cache_dir.mkdir(parents=True, exist_ok=True)
         return cache_dir
         return cache_dir
 
 
-    def _get_cached_firmware_path(self, model: str, version: str) -> Path:
-        """Get the path where a firmware file would be cached."""
-        # Normalize model name for filename
-        model_safe = model.upper().replace(" ", "-").replace("/", "-")
-        version_safe = version.replace(".", "_")
-        filename = f"{model_safe}_{version_safe}.bin"
-        return self._get_firmware_cache_dir() / filename
-
     async def get_firmware_file_info(self, model: str) -> dict | None:
     async def get_firmware_file_info(self, model: str) -> dict | None:
         """
         """
         Get information about the firmware file for a model.
         Get information about the firmware file for a model.
@@ -374,16 +366,16 @@ class FirmwareCheckService:
             logger.warning("No firmware download URL available for model: %s", model)
             logger.warning("No firmware download URL available for model: %s", model)
             return None
             return None
 
 
-        # Check if already cached
-        cached_path = self._get_cached_firmware_path(model, latest.version)
-        if cached_path.exists():
-            logger.info("Using cached firmware: %s", cached_path)
-            return cached_path
-
         # Extract original filename from URL (must preserve for SD card update)
         # Extract original filename from URL (must preserve for SD card update)
         url_parts = latest.download_url.split("/")
         url_parts = latest.download_url.split("/")
         original_filename = url_parts[-1] if url_parts else f"firmware_{model}.bin"
         original_filename = url_parts[-1] if url_parts else f"firmware_{model}.bin"
 
 
+        # Check if already cached (using original filename so SD card gets the right name)
+        cached_path = self._get_firmware_cache_dir() / original_filename
+        if cached_path.exists():
+            logger.info("Using cached firmware: %s", cached_path)
+            return cached_path
+
         # Download to temp file first
         # Download to temp file first
         temp_path = self._get_firmware_cache_dir() / f".downloading_{original_filename}"
         temp_path = self._get_firmware_cache_dir() / f".downloading_{original_filename}"
 
 
@@ -407,22 +399,14 @@ class FirmwareCheckService:
                         if progress_callback:
                         if progress_callback:
                             progress_callback(downloaded, total_size, "Downloading firmware...")
                             progress_callback(downloaded, total_size, "Downloading firmware...")
 
 
-            # Also save a copy with the original filename for SD card
-            original_path = self._get_firmware_cache_dir() / original_filename
-            if original_path.exists():
-                original_path.unlink()
+            # Move temp to final path, preserving original filename
+            temp_path.rename(cached_path)
 
 
-            # Move temp to both cached path and original filename path
-            import shutil
-
-            shutil.copy2(temp_path, cached_path)
-            temp_path.rename(original_path)
-
-            logger.info("Firmware downloaded successfully: %s", original_path)
+            logger.info("Firmware downloaded successfully: %s", cached_path)
             if progress_callback:
             if progress_callback:
                 progress_callback(downloaded, total_size, "Download complete")
                 progress_callback(downloaded, total_size, "Download complete")
 
 
-            return original_path
+            return cached_path
 
 
         except Exception as e:
         except Exception as e:
             logger.error("Firmware download failed: %s", e)
             logger.error("Firmware download failed: %s", e)

+ 52 - 0
backend/app/services/print_log.py

@@ -0,0 +1,52 @@
+"""Service for writing independent print log entries.
+
+Log entries are written to a separate table and never touch archives or queue items.
+"""
+
+import logging
+from datetime import datetime
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.print_log import PrintLogEntry
+
+logger = logging.getLogger(__name__)
+
+
+async def write_log_entry(
+    db: AsyncSession,
+    *,
+    status: str,
+    print_name: str | None = None,
+    printer_name: str | None = None,
+    printer_id: int | None = None,
+    started_at: datetime | None = None,
+    completed_at: datetime | None = None,
+    filament_type: str | None = None,
+    filament_color: str | None = None,
+    filament_used_grams: float | None = None,
+    thumbnail_path: str | None = None,
+    created_by_username: str | None = None,
+) -> PrintLogEntry:
+    """Write a print log entry."""
+    duration = None
+    if started_at and completed_at:
+        duration = int((completed_at - started_at).total_seconds())
+
+    entry = PrintLogEntry(
+        print_name=print_name,
+        printer_name=printer_name,
+        printer_id=printer_id,
+        status=status,
+        started_at=started_at,
+        completed_at=completed_at,
+        duration_seconds=duration,
+        filament_type=filament_type,
+        filament_color=filament_color,
+        filament_used_grams=filament_used_grams,
+        thumbnail_path=thumbnail_path,
+        created_by_username=created_by_username,
+    )
+    db.add(entry)
+    await db.flush()
+    return entry

+ 104 - 0
backend/tests/unit/test_print_log.py

@@ -0,0 +1,104 @@
+"""Unit tests for print log service and schema."""
+
+from datetime import datetime, timedelta
+
+import pytest
+
+from backend.app.schemas.print_log import PrintLogEntrySchema, PrintLogResponse
+
+
+class TestPrintLogEntrySchema:
+    """Test PrintLogEntrySchema validation."""
+
+    def test_minimal_entry(self):
+        """Schema accepts minimal required fields."""
+        entry = PrintLogEntrySchema(
+            id=1,
+            status="completed",
+            created_at=datetime(2024, 1, 15, 10, 30, 0),
+        )
+        assert entry.id == 1
+        assert entry.status == "completed"
+        assert entry.print_name is None
+        assert entry.printer_name is None
+        assert entry.duration_seconds is None
+
+    def test_full_entry(self):
+        """Schema accepts all fields."""
+        started = datetime(2024, 1, 15, 10, 0, 0)
+        completed = datetime(2024, 1, 15, 12, 30, 0)
+        entry = PrintLogEntrySchema(
+            id=42,
+            print_name="Benchy",
+            printer_name="X1C-01",
+            printer_id=3,
+            status="completed",
+            started_at=started,
+            completed_at=completed,
+            duration_seconds=9000,
+            filament_type="PLA",
+            filament_color="#FF5500",
+            filament_used_grams=15.2,
+            thumbnail_path="archives/3/20240115_benchy/thumbnail.png",
+            created_by_username="admin",
+            created_at=datetime(2024, 1, 15, 12, 30, 0),
+        )
+        assert entry.print_name == "Benchy"
+        assert entry.printer_name == "X1C-01"
+        assert entry.filament_used_grams == 15.2
+        assert entry.created_by_username == "admin"
+
+    def test_failed_status(self):
+        """Schema accepts various status values."""
+        for status in ("completed", "failed", "stopped", "cancelled", "skipped"):
+            entry = PrintLogEntrySchema(id=1, status=status, created_at=datetime.now())
+            assert entry.status == status
+
+
+class TestPrintLogResponse:
+    """Test PrintLogResponse pagination wrapper."""
+
+    def test_empty_response(self):
+        """Empty response with zero total."""
+        resp = PrintLogResponse(items=[], total=0)
+        assert len(resp.items) == 0
+        assert resp.total == 0
+
+    def test_paginated_response(self):
+        """Response with items and total count > items count."""
+        items = [PrintLogEntrySchema(id=i, status="completed", created_at=datetime.now()) for i in range(3)]
+        resp = PrintLogResponse(items=items, total=100)
+        assert len(resp.items) == 3
+        assert resp.total == 100
+
+
+class TestWriteLogEntry:
+    """Test the write_log_entry service function (logic only, no DB)."""
+
+    def test_duration_calculation(self):
+        """Duration is computed from started_at and completed_at."""
+        started = datetime(2024, 1, 15, 10, 0, 0)
+        completed = started + timedelta(hours=2, minutes=30)
+
+        # Simulating the duration calculation from write_log_entry
+        duration = int((completed - started).total_seconds())
+        assert duration == 9000  # 2.5 hours = 9000 seconds
+
+    def test_duration_none_when_missing_times(self):
+        """Duration is None when started_at or completed_at is missing."""
+        started = datetime(2024, 1, 15, 10, 0, 0)
+        completed_at = None
+        started_at = None
+        completed = datetime.now()
+
+        # No completed_at
+        duration = None
+        if started and completed_at:
+            duration = int((completed_at - started).total_seconds())
+        assert duration is None
+
+        # No started_at
+        duration = None
+        if started_at and completed:
+            duration = int((completed - started_at).total_seconds())
+        assert duration is None

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

@@ -369,6 +369,28 @@ export interface Archive {
   created_by_username: string | null;
   created_by_username: string | null;
 }
 }
 
 
+export interface PrintLogEntry {
+  id: number;
+  print_name: string | null;
+  printer_name: string | null;
+  printer_id: number | null;
+  status: string;
+  started_at: string | null;
+  completed_at: string | null;
+  duration_seconds: number | null;
+  filament_type: string | null;
+  filament_color: string | null;
+  filament_used_grams: number | null;
+  thumbnail_path: string | null;
+  created_by_username: string | null;
+  created_at: string;
+}
+
+export interface PrintLogResponse {
+  items: PrintLogEntry[];
+  total: number;
+}
+
 export interface ArchiveStats {
 export interface ArchiveStats {
   total_prints: number;
   total_prints: number;
   successful_prints: number;
   successful_prints: number;
@@ -2954,6 +2976,32 @@ export const api = {
     return response.json();
     return response.json();
   },
   },
 
 
+  // Print Log
+  getPrintLog: (params?: {
+    search?: string;
+    printerId?: number;
+    username?: string;
+    status?: string;
+    dateFrom?: string;
+    dateTo?: string;
+    limit?: number;
+    offset?: number;
+  }) => {
+    const searchParams = new URLSearchParams();
+    if (params?.search) searchParams.set('search', params.search);
+    if (params?.printerId) searchParams.set('printer_id', String(params.printerId));
+    if (params?.username) searchParams.set('created_by_username', params.username);
+    if (params?.status) searchParams.set('status', params.status);
+    if (params?.dateFrom) searchParams.set('date_from', params.dateFrom);
+    if (params?.dateTo) searchParams.set('date_to', params.dateTo);
+    if (params?.limit) searchParams.set('limit', String(params.limit));
+    if (params?.offset !== undefined) searchParams.set('offset', String(params.offset));
+    return request<PrintLogResponse>(`/print-log/?${searchParams}`);
+  },
+  getPrintLogThumbnail: (id: number) => `${API_BASE}/print-log/${id}/thumbnail`,
+  clearPrintLog: () =>
+    request<{ deleted: number }>('/print-log/', { method: 'DELETE' }),
+
   // Settings
   // Settings
   getSettings: () => request<AppSettings>('/settings/'),
   getSettings: () => request<AppSettings>('/settings/'),
   updateSettings: (data: AppSettingsUpdate) =>
   updateSettings: (data: AppSettingsUpdate) =>

+ 2 - 2
frontend/src/components/Layout.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
 import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
-import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Package, type LucideIcon } from 'lucide-react';
+import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, type LucideIcon } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -29,7 +29,7 @@ export const defaultNavItems: NavItem[] = [
   { id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' },
   { id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' },
   { id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' },
   { id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' },
   { id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
   { id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
-  { id: 'inventory', to: '/inventory', icon: Package, labelKey: 'nav.inventory' },
+  { id: 'inventory', to: '/inventory', icon: Disc3, labelKey: 'nav.inventory' },
   { id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },
   { id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },
   { id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
   { id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
 ];
 ];

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

@@ -484,6 +484,7 @@ export default {
     gridView: 'Rasteransicht',
     gridView: 'Rasteransicht',
     listView: 'Listenansicht',
     listView: 'Listenansicht',
     calendarView: 'Kalenderansicht',
     calendarView: 'Kalenderansicht',
+    logView: 'Druckprotokoll',
     manageTags: 'Tags verwalten',
     manageTags: 'Tags verwalten',
     showFailedPrints: 'Fehlgeschlagene Drucke anzeigen',
     showFailedPrints: 'Fehlgeschlagene Drucke anzeigen',
     hideFailedPrints: 'Fehlgeschlagene Drucke ausblenden',
     hideFailedPrints: 'Fehlgeschlagene Drucke ausblenden',
@@ -672,6 +673,34 @@ export default {
       actions: 'Aktionen',
       actions: 'Aktionen',
       hasTimelapse: 'Hat Zeitraffer',
       hasTimelapse: 'Hat Zeitraffer',
     },
     },
+    log: {
+      date: 'Datum',
+      printName: 'Druckname',
+      printer: 'Drucker',
+      user: 'Benutzer',
+      status: 'Status',
+      duration: 'Dauer',
+      filament: 'Filament',
+      allPrinters: 'Alle Drucker',
+      allUsers: 'Alle Benutzer',
+      allStatuses: 'Alle Status',
+      cancelled: 'Abgebrochen',
+      skipped: 'Übersprungen',
+      dateFrom: 'Von',
+      dateTo: 'Bis',
+      noEntries: 'Keine Druckprotokolleinträge gefunden',
+      showing: '{{count}} von {{total}} Einträgen',
+      rowsPerPage: 'Zeilen',
+      page: 'Seite',
+      prev: 'Zurück',
+      next: 'Weiter',
+      clearLog: 'Protokoll löschen',
+      clearLogTitle: 'Druckprotokoll löschen',
+      clearLogConfirm: 'Alle Druckprotokolleinträge werden dauerhaft gelöscht. Archive und Warteschlangeneinträge sind nicht betroffen. Diese Aktion kann nicht rückgängig gemacht werden. Sind Sie sicher?',
+      clearLogButton: 'Alle löschen',
+      cleared: '{{count}} Protokolleinträge gelöscht',
+      clearFailed: 'Druckprotokoll konnte nicht gelöscht werden',
+    },
   },
   },
 
 
   // Queue page
   // Queue page

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

@@ -484,6 +484,7 @@ export default {
     gridView: 'Grid view',
     gridView: 'Grid view',
     listView: 'List view',
     listView: 'List view',
     calendarView: 'Calendar view',
     calendarView: 'Calendar view',
+    logView: 'Print Log',
     manageTags: 'Manage Tags',
     manageTags: 'Manage Tags',
     showFailedPrints: 'Show failed prints',
     showFailedPrints: 'Show failed prints',
     hideFailedPrints: 'Hide failed prints',
     hideFailedPrints: 'Hide failed prints',
@@ -672,6 +673,34 @@ export default {
       actions: 'Actions',
       actions: 'Actions',
       hasTimelapse: 'Has timelapse',
       hasTimelapse: 'Has timelapse',
     },
     },
+    log: {
+      date: 'Date',
+      printName: 'Print Name',
+      printer: 'Printer',
+      user: 'User',
+      status: 'Status',
+      duration: 'Duration',
+      filament: 'Filament',
+      allPrinters: 'All Printers',
+      allUsers: 'All Users',
+      allStatuses: 'All Statuses',
+      cancelled: 'Cancelled',
+      skipped: 'Skipped',
+      dateFrom: 'From',
+      dateTo: 'To',
+      noEntries: 'No print log entries found',
+      showing: 'Showing {{count}} of {{total}} entries',
+      rowsPerPage: 'Rows',
+      page: 'Page',
+      prev: 'Prev',
+      next: 'Next',
+      clearLog: 'Clear Log',
+      clearLogTitle: 'Clear Print Log',
+      clearLogConfirm: 'All print log entries will be permanently deleted. Archives and queue items are not affected. This action cannot be undone. Are you sure?',
+      clearLogButton: 'Clear All',
+      cleared: '{{count}} log entries cleared',
+      clearFailed: 'Failed to clear print log',
+    },
   },
   },
 
 
   // Queue page
   // Queue page

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

@@ -484,6 +484,7 @@ export default {
     gridView: 'Grille',
     gridView: 'Grille',
     listView: 'Liste',
     listView: 'Liste',
     calendarView: 'Calendrier',
     calendarView: 'Calendrier',
+    logView: 'Journal d\'impression',
     manageTags: 'Gérer les tags',
     manageTags: 'Gérer les tags',
     showFailedPrints: 'Afficher les échecs',
     showFailedPrints: 'Afficher les échecs',
     hideFailedPrints: 'Masquer les échecs',
     hideFailedPrints: 'Masquer les échecs',
@@ -672,6 +673,34 @@ export default {
       actions: 'Actions',
       actions: 'Actions',
       hasTimelapse: 'A un timelapse',
       hasTimelapse: 'A un timelapse',
     },
     },
+    log: {
+      date: 'Date',
+      printName: 'Nom de l\'impression',
+      printer: 'Imprimante',
+      user: 'Utilisateur',
+      status: 'Statut',
+      duration: 'Durée',
+      filament: 'Filament',
+      allPrinters: 'Toutes les imprimantes',
+      allUsers: 'Tous les utilisateurs',
+      allStatuses: 'Tous les statuts',
+      cancelled: 'Annulé',
+      skipped: 'Ignoré',
+      dateFrom: 'Du',
+      dateTo: 'Au',
+      noEntries: 'Aucune entrée de journal trouvée',
+      showing: '{{count}} sur {{total}} entrées',
+      rowsPerPage: 'Lignes',
+      page: 'Page',
+      prev: 'Préc.',
+      next: 'Suiv.',
+      clearLog: 'Effacer le journal',
+      clearLogTitle: 'Effacer le journal d\'impression',
+      clearLogConfirm: 'Toutes les entrées du journal d\'impression seront supprimées définitivement. Les archives et les éléments de file d\'attente ne sont pas affectés. Cette action est irréversible. Êtes-vous sûr ?',
+      clearLogButton: 'Tout effacer',
+      cleared: '{{count}} entrées de journal effacées',
+      clearFailed: 'Échec de l\'effacement du journal d\'impression',
+    },
   },
   },
 
 
   // Queue page
   // Queue page

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

@@ -475,6 +475,7 @@ export default {
     gridView: 'Vista griglia',
     gridView: 'Vista griglia',
     listView: 'Vista elenco',
     listView: 'Vista elenco',
     calendarView: 'Vista calendario',
     calendarView: 'Vista calendario',
+    logView: 'Registro stampe',
     manageTags: 'Gestisci tag',
     manageTags: 'Gestisci tag',
     showFailedPrints: 'Mostra stampe fallite',
     showFailedPrints: 'Mostra stampe fallite',
     hideFailedPrints: 'Nascondi stampe fallite',
     hideFailedPrints: 'Nascondi stampe fallite',
@@ -663,6 +664,34 @@ export default {
       actions: 'Azioni',
       actions: 'Azioni',
       hasTimelapse: 'Ha timelapse',
       hasTimelapse: 'Ha timelapse',
     },
     },
+    log: {
+      date: 'Data',
+      printName: 'Nome stampa',
+      printer: 'Stampante',
+      user: 'Utente',
+      status: 'Stato',
+      duration: 'Durata',
+      filament: 'Filamento',
+      allPrinters: 'Tutte le stampanti',
+      allUsers: 'Tutti gli utenti',
+      allStatuses: 'Tutti gli stati',
+      cancelled: 'Annullato',
+      skipped: 'Saltato',
+      dateFrom: 'Dal',
+      dateTo: 'Al',
+      noEntries: 'Nessuna voce di registro trovata',
+      showing: '{{count}} di {{total}} voci',
+      rowsPerPage: 'Righe',
+      page: 'Pagina',
+      prev: 'Prec.',
+      next: 'Succ.',
+      clearLog: 'Cancella registro',
+      clearLogTitle: 'Cancella registro stampe',
+      clearLogConfirm: 'Tutte le voci del registro di stampa verranno eliminate permanentemente. Gli archivi e gli elementi della coda non sono interessati. Questa azione non può essere annullata. Sei sicuro?',
+      clearLogButton: 'Cancella tutto',
+      cleared: '{{count}} voci di registro cancellate',
+      clearFailed: 'Impossibile cancellare il registro stampe',
+    },
   },
   },
 
 
   // Queue page
   // Queue page

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

@@ -484,6 +484,7 @@ export default {
     gridView: 'グリッド表示',
     gridView: 'グリッド表示',
     listView: 'リスト表示',
     listView: 'リスト表示',
     calendarView: 'カレンダー表示',
     calendarView: 'カレンダー表示',
+    logView: '印刷ログ',
     showFailedPrints: '失敗した印刷を表示',
     showFailedPrints: '失敗した印刷を表示',
     hideFailedPrints: '失敗した印刷を非表示',
     hideFailedPrints: '失敗した印刷を非表示',
     printTime: '印刷時間',
     printTime: '印刷時間',
@@ -677,6 +678,34 @@ export default {
       date: '日付',
       date: '日付',
       actions: '操作',
       actions: '操作',
     },
     },
+    log: {
+      date: '日時',
+      printName: '印刷名',
+      printer: 'プリンター',
+      user: 'ユーザー',
+      status: 'ステータス',
+      duration: '所要時間',
+      filament: 'フィラメント',
+      allPrinters: '全プリンター',
+      allUsers: '全ユーザー',
+      allStatuses: '全ステータス',
+      cancelled: 'キャンセル',
+      skipped: 'スキップ',
+      dateFrom: '開始日',
+      dateTo: '終了日',
+      noEntries: '印刷ログが見つかりません',
+      showing: '{{total}}件中{{count}}件を表示',
+      rowsPerPage: '行数',
+      page: 'ページ',
+      prev: '前へ',
+      next: '次へ',
+      clearLog: 'ログをクリア',
+      clearLogTitle: '印刷ログをクリア',
+      clearLogConfirm: 'すべての印刷ログエントリが完全に削除されます。アーカイブとキューアイテムには影響しません。この操作は元に戻せません。よろしいですか?',
+      clearLogButton: 'すべてクリア',
+      cleared: '{{count}}件のログエントリを削除しました',
+      clearFailed: '印刷ログの削除に失敗しました',
+    },
     noPrinterAvailable: '利用可能なプリンターがありません',
     noPrinterAvailable: '利用可能なプリンターがありません',
     archiveOrReprint: 'アーカイブまたは再印刷',
     archiveOrReprint: 'アーカイブまたは再印刷',
     multiPrinterPrint: 'マルチプリンター印刷',
     multiPrinterPrint: 'マルチプリンター印刷',

+ 354 - 27
frontend/src/pages/ArchivesPage.tsx

@@ -46,6 +46,7 @@ import {
   ChevronRight,
   ChevronRight,
   Settings,
   Settings,
   User,
   User,
+  ClipboardList,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { openInSlicer, type SlicerType } from '../utils/slicer';
 import { openInSlicer, type SlicerType } from '../utils/slicer';
@@ -2064,7 +2065,7 @@ function ArchiveListRow({
 }
 }
 
 
 type SortOption = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'size-desc' | 'size-asc';
 type SortOption = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'size-desc' | 'size-asc';
-type ViewMode = 'grid' | 'list' | 'calendar';
+type ViewMode = 'grid' | 'list' | 'calendar' | 'log';
 type Collection = 'all' | 'recent' | 'this-week' | 'this-month' | 'favorites' | 'failed' | 'duplicates';
 type Collection = 'all' | 'recent' | 'this-week' | 'this-month' | 'favorites' | 'failed' | 'duplicates';
 
 
 const collections: { id: Collection; label: string; icon: React.ReactNode }[] = [
 const collections: { id: Collection; label: string; icon: React.ReactNode }[] = [
@@ -2133,6 +2134,29 @@ export function ArchivesPage() {
   const [showTagManagement, setShowTagManagement] = useState(false);
   const [showTagManagement, setShowTagManagement] = useState(false);
   const [highlightedArchiveId, setHighlightedArchiveId] = useState<number | null>(null);
   const [highlightedArchiveId, setHighlightedArchiveId] = useState<number | null>(null);
 
 
+  // Log view state
+  const [logFilterUser, setLogFilterUser] = useState<string | null>(() =>
+    localStorage.getItem('logFilterUser') || null
+  );
+  const [logFilterStatus, setLogFilterStatus] = useState<string | null>(() =>
+    localStorage.getItem('logFilterStatus')
+  );
+  const [logFilterDateFrom, setLogFilterDateFrom] = useState(() =>
+    localStorage.getItem('logFilterDateFrom') || ''
+  );
+  const [logFilterDateTo, setLogFilterDateTo] = useState(() =>
+    localStorage.getItem('logFilterDateTo') || ''
+  );
+  const [logOffset, setLogOffset] = useState(() => {
+    const saved = localStorage.getItem('logOffset');
+    return saved ? Number(saved) : 0;
+  });
+  const [showClearLogConfirm, setShowClearLogConfirm] = useState(false);
+  const [logPageSize, setLogPageSize] = useState(() => {
+    const saved = localStorage.getItem('logPageSize');
+    return saved ? Number(saved) : 25;
+  });
+
   // Clear highlight after 5 seconds and scroll to highlighted element
   // Clear highlight after 5 seconds and scroll to highlighted element
   useEffect(() => {
   useEffect(() => {
     if (highlightedArchiveId) {
     if (highlightedArchiveId) {
@@ -2173,6 +2197,27 @@ export function ArchivesPage() {
     queryFn: api.getSettings,
     queryFn: api.getSettings,
   });
   });
 
 
+  const { data: users } = useQuery({
+    queryKey: ['users'],
+    queryFn: api.getUsers,
+    enabled: viewMode === 'log',
+  });
+
+  const { data: printLogData, isLoading: isLogLoading } = useQuery({
+    queryKey: ['print-log', filterPrinter, logFilterUser, logFilterStatus, logFilterDateFrom, logFilterDateTo, search, logOffset, logPageSize],
+    queryFn: () => api.getPrintLog({
+      search: search || undefined,
+      printerId: filterPrinter || undefined,
+      username: logFilterUser || undefined,
+      status: logFilterStatus || undefined,
+      dateFrom: logFilterDateFrom || undefined,
+      dateTo: logFilterDateTo || undefined,
+      limit: logPageSize,
+      offset: logOffset,
+    }),
+    enabled: viewMode === 'log',
+  });
+
   const timeFormat: TimeFormat = settings?.time_format || 'system';
   const timeFormat: TimeFormat = settings?.time_format || 'system';
   const preferredSlicer: SlicerType = settings?.preferred_slicer || 'bambu_studio';
   const preferredSlicer: SlicerType = settings?.preferred_slicer || 'bambu_studio';
 
 
@@ -2191,6 +2236,18 @@ export function ArchivesPage() {
     },
     },
   });
   });
 
 
+  const clearLogMutation = useMutation({
+    mutationFn: () => api.clearPrintLog(),
+    onSuccess: (data) => {
+      queryClient.invalidateQueries({ queryKey: ['print-log'] });
+      setLogOffset(0);
+      showToast(t('archives.log.cleared', { count: data.deleted }));
+    },
+    onError: () => {
+      showToast(t('archives.log.clearFailed'), 'error');
+    },
+  });
+
   // Persist all filters to localStorage
   // Persist all filters to localStorage
   useEffect(() => {
   useEffect(() => {
     if (filterPrinter !== null) {
     if (filterPrinter !== null) {
@@ -2248,6 +2305,47 @@ export function ArchivesPage() {
     localStorage.setItem('archiveCollection', collection);
     localStorage.setItem('archiveCollection', collection);
   }, [collection]);
   }, [collection]);
 
 
+  // Persist log view filters
+  useEffect(() => {
+    if (logFilterUser) {
+      localStorage.setItem('logFilterUser', logFilterUser);
+    } else {
+      localStorage.removeItem('logFilterUser');
+    }
+  }, [logFilterUser]);
+
+  useEffect(() => {
+    if (logFilterStatus) {
+      localStorage.setItem('logFilterStatus', logFilterStatus);
+    } else {
+      localStorage.removeItem('logFilterStatus');
+    }
+  }, [logFilterStatus]);
+
+  useEffect(() => {
+    if (logFilterDateFrom) {
+      localStorage.setItem('logFilterDateFrom', logFilterDateFrom);
+    } else {
+      localStorage.removeItem('logFilterDateFrom');
+    }
+  }, [logFilterDateFrom]);
+
+  useEffect(() => {
+    if (logFilterDateTo) {
+      localStorage.setItem('logFilterDateTo', logFilterDateTo);
+    } else {
+      localStorage.removeItem('logFilterDateTo');
+    }
+  }, [logFilterDateTo]);
+
+  useEffect(() => {
+    localStorage.setItem('logOffset', logOffset.toString());
+  }, [logOffset]);
+
+  useEffect(() => {
+    localStorage.setItem('logPageSize', logPageSize.toString());
+  }, [logPageSize]);
+
   const printerMap = new Map(printers?.map((p) => [p.id, p.name]) || []);
   const printerMap = new Map(printers?.map((p) => [p.id, p.name]) || []);
 
 
   // Extract unique materials and colors from archives
   // Extract unique materials and colors from archives
@@ -2672,8 +2770,40 @@ export function ArchivesPage() {
         </div>
         </div>
       </div>
       </div>
 
 
-      {/* Filters */}
-      <Card className="mb-6">
+      {/* View mode toggle — always visible */}
+      <div className="flex items-center border border-bambu-dark-tertiary rounded-lg overflow-hidden flex-shrink-0 w-fit mb-4">
+        <button
+          className={`p-2 ${viewMode === 'grid' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
+          onClick={() => setViewMode('grid')}
+          title={t('archives.gridView')}
+        >
+          <LayoutGrid className="w-4 h-4" />
+        </button>
+        <button
+          className={`p-2 ${viewMode === 'list' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
+          onClick={() => setViewMode('list')}
+          title={t('archives.listView')}
+        >
+          <List className="w-4 h-4" />
+        </button>
+        <button
+          className={`p-2 ${viewMode === 'calendar' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
+          onClick={() => setViewMode('calendar')}
+          title={t('archives.calendarView')}
+        >
+          <CalendarDays className="w-4 h-4" />
+        </button>
+        <button
+          className={`p-2 ${viewMode === 'log' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
+          onClick={() => setViewMode('log')}
+          title={t('archives.logView')}
+        >
+          <ClipboardList className="w-4 h-4" />
+        </button>
+      </div>
+
+      {/* Filters (hidden in log view which has its own filters) */}
+      {viewMode !== 'log' && <Card className="mb-6">
         <CardContent className="py-4">
         <CardContent className="py-4">
           <div className="flex flex-col md:flex-row gap-3 md:gap-4 md:items-center md:flex-wrap">
           <div className="flex flex-col md:flex-row gap-3 md:gap-4 md:items-center md:flex-wrap">
             {/* Search - full width on mobile */}
             {/* Search - full width on mobile */}
@@ -2799,29 +2929,6 @@ export function ArchivesPage() {
                 <option value="size-asc">{t('archives.sortSmallest')}</option>
                 <option value="size-asc">{t('archives.sortSmallest')}</option>
               </select>
               </select>
             </div>
             </div>
-            <div className="flex items-center border border-bambu-dark-tertiary rounded-lg overflow-hidden flex-shrink-0">
-              <button
-                className={`p-2 ${viewMode === 'grid' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
-                onClick={() => setViewMode('grid')}
-                title={t('archives.gridView')}
-              >
-                <LayoutGrid className="w-4 h-4" />
-              </button>
-              <button
-                className={`p-2 ${viewMode === 'list' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
-                onClick={() => setViewMode('list')}
-                title={t('archives.listView')}
-              >
-                <List className="w-4 h-4" />
-              </button>
-              <button
-                className={`p-2 ${viewMode === 'calendar' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
-                onClick={() => setViewMode('calendar')}
-                title={t('archives.calendarView')}
-              >
-                <CalendarDays className="w-4 h-4" />
-              </button>
-            </div>
             </div>
             </div>
             {hasTopFilters && (
             {hasTopFilters && (
               <Button
               <Button
@@ -2879,7 +2986,7 @@ export function ArchivesPage() {
             </div>
             </div>
           )}
           )}
         </CardContent>
         </CardContent>
-      </Card>
+      </Card>}
 
 
       {/* Pending Uploads Panel (visible when in queue mode with pending files) */}
       {/* Pending Uploads Panel (visible when in queue mode with pending files) */}
       <PendingUploadsPanel />
       <PendingUploadsPanel />
@@ -2958,6 +3065,211 @@ export function ArchivesPage() {
             ))}
             ))}
           </div>
           </div>
         </Card>
         </Card>
+      ) : viewMode === 'log' ? (
+        <div className="space-y-4">
+          {/* Log filters */}
+          <Card>
+            <CardContent className="py-3">
+              <div className="flex flex-col md:flex-row gap-3 md:items-center md:flex-wrap">
+                {/* Search */}
+                <div className="flex-1 relative md:min-w-[200px]">
+                  <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
+                  <input
+                    type="text"
+                    placeholder={t('archives.searchPlaceholder')}
+                    className="w-full pl-10 pr-4 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
+                    value={search}
+                    onChange={(e) => { setSearch(e.target.value); setLogOffset(0); }}
+                  />
+                </div>
+                {/* Printer filter */}
+                <select
+                  className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
+                  value={filterPrinter || ''}
+                  onChange={(e) => { setFilterPrinter(e.target.value ? Number(e.target.value) : null); setLogOffset(0); }}
+                >
+                  <option value="">{t('archives.log.allPrinters')}</option>
+                  {printers?.map((p) => (
+                    <option key={p.id} value={p.id}>{p.name}</option>
+                  ))}
+                </select>
+                {/* User filter */}
+                <select
+                  className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
+                  value={logFilterUser || ''}
+                  onChange={(e) => { setLogFilterUser(e.target.value || null); setLogOffset(0); }}
+                >
+                  <option value="">{t('archives.log.allUsers')}</option>
+                  {users?.map((u) => (
+                    <option key={u.id} value={u.username}>{u.username}</option>
+                  ))}
+                </select>
+                {/* Status filter */}
+                <select
+                  className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
+                  value={logFilterStatus || ''}
+                  onChange={(e) => { setLogFilterStatus(e.target.value || null); setLogOffset(0); }}
+                >
+                  <option value="">{t('archives.log.allStatuses')}</option>
+                  <option value="completed">{t('archives.status.completed')}</option>
+                  <option value="failed">{t('archives.status.failed')}</option>
+                  <option value="stopped">{t('archives.status.stopped')}</option>
+                  <option value="cancelled">{t('archives.log.cancelled')}</option>
+                  <option value="skipped">{t('archives.log.skipped')}</option>
+                </select>
+                {/* Date range */}
+                <div className="flex items-center gap-2">
+                  <label className="text-sm text-bambu-gray">{t('archives.log.dateFrom')}</label>
+                  <input
+                    type="date"
+                    className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
+                    value={logFilterDateFrom}
+                    onChange={(e) => { setLogFilterDateFrom(e.target.value); setLogOffset(0); }}
+                  />
+                </div>
+                <div className="flex items-center gap-2">
+                  <label className="text-sm text-bambu-gray">{t('archives.log.dateTo')}</label>
+                  <input
+                    type="date"
+                    className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
+                    value={logFilterDateTo}
+                    onChange={(e) => { setLogFilterDateTo(e.target.value); setLogOffset(0); }}
+                  />
+                </div>
+                {/* Clear log button */}
+                <div className="ml-auto">
+                  <Button
+                    variant="danger"
+                    size="sm"
+                    onClick={() => setShowClearLogConfirm(true)}
+                    disabled={!hasPermission('archives:delete_all') || clearLogMutation.isPending}
+                  >
+                    <Trash2 className="w-4 h-4" />
+                    {t('archives.log.clearLog')}
+                  </Button>
+                </div>
+              </div>
+            </CardContent>
+          </Card>
+
+          {/* Log table */}
+          <Card>
+            {isLogLoading ? (
+              <div className="flex items-center justify-center py-12">
+                <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
+              </div>
+            ) : !printLogData?.items.length ? (
+              <div className="text-center py-12 text-bambu-gray">
+                {t('archives.log.noEntries')}
+              </div>
+            ) : (
+              <>
+                <div className="overflow-x-auto">
+                  <table className="w-full text-sm">
+                    <thead>
+                      <tr className="border-b border-bambu-dark-tertiary text-bambu-gray text-left">
+                        <th className="px-4 py-3 font-medium">{t('archives.log.date')}</th>
+                        <th className="px-4 py-3 font-medium">{t('archives.log.printName')}</th>
+                        <th className="px-4 py-3 font-medium">{t('archives.log.printer')}</th>
+                        <th className="px-4 py-3 font-medium">{t('archives.log.user')}</th>
+                        <th className="px-4 py-3 font-medium">{t('archives.log.status')}</th>
+                        <th className="px-4 py-3 font-medium">{t('archives.log.duration')}</th>
+                        <th className="px-4 py-3 font-medium">{t('archives.log.filament')}</th>
+                      </tr>
+                    </thead>
+                    <tbody className="divide-y divide-bambu-dark-tertiary">
+                      {printLogData.items.map((entry) => (
+                        <tr key={entry.id} className="hover:bg-bambu-dark-secondary/50">
+                          <td className="px-4 py-3 text-white whitespace-nowrap">
+                            {formatDateTime(entry.started_at || entry.created_at, timeFormat)}
+                          </td>
+                          <td className="px-4 py-3">
+                            <div className="flex items-center gap-2">
+                              {entry.thumbnail_path && (
+                                <img
+                                  src={api.getPrintLogThumbnail(entry.id)}
+                                  alt=""
+                                  className="w-8 h-8 rounded object-cover flex-shrink-0"
+                                  onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
+                                />
+                              )}
+                              <span className="text-white truncate max-w-[200px]">
+                                {entry.print_name || '—'}
+                              </span>
+                            </div>
+                          </td>
+                          <td className="px-4 py-3 text-bambu-gray-light">{entry.printer_name || '—'}</td>
+                          <td className="px-4 py-3 text-bambu-gray-light">{entry.created_by_username || '—'}</td>
+                          <td className="px-4 py-3">
+                            <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
+                              entry.status === 'completed' ? 'bg-green-500/20 text-green-400' :
+                              entry.status === 'failed' ? 'bg-red-500/20 text-red-400' :
+                              entry.status === 'stopped' ? 'bg-yellow-500/20 text-yellow-400' :
+                              entry.status === 'cancelled' ? 'bg-orange-500/20 text-orange-400' :
+                              entry.status === 'skipped' ? 'bg-blue-500/20 text-blue-400' :
+                              'bg-gray-500/20 text-gray-400'
+                            }`}>
+                              {entry.status}
+                            </span>
+                          </td>
+                          <td className="px-4 py-3 text-bambu-gray-light whitespace-nowrap">
+                            {entry.duration_seconds ? formatDuration(entry.duration_seconds) : '—'}
+                          </td>
+                          <td className="px-4 py-3">
+                            <div className="flex items-center gap-1.5">
+                              {entry.filament_color && (
+                                <span
+                                  className="w-3 h-3 rounded-full border border-white/20 flex-shrink-0"
+                                  style={{ backgroundColor: entry.filament_color.startsWith('#') ? entry.filament_color : undefined }}
+                                />
+                              )}
+                              <span className="text-bambu-gray-light text-xs">
+                                {entry.filament_type || '—'}
+                              </span>
+                            </div>
+                          </td>
+                        </tr>
+                      ))}
+                    </tbody>
+                  </table>
+                </div>
+                {/* Pagination */}
+                <div className="flex items-center justify-between px-4 py-3 border-t border-bambu-dark-tertiary flex-wrap gap-2">
+                  <div className="flex items-center gap-3">
+                    <span className="text-sm text-bambu-gray">
+                      {t('archives.log.showing', { count: Math.min(logOffset + logPageSize, printLogData.total), total: printLogData.total })}
+                    </span>
+                    <div className="flex items-center gap-1.5">
+                      <label className="text-xs text-bambu-gray">{t('archives.log.rowsPerPage')}</label>
+                      <select
+                        className="px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-xs focus:border-bambu-green focus:outline-none"
+                        value={logPageSize}
+                        onChange={(e) => { setLogPageSize(Number(e.target.value)); setLogOffset(0); }}
+                      >
+                        <option value={10}>10</option>
+                        <option value={25}>25</option>
+                        <option value={50}>50</option>
+                        <option value={100}>100</option>
+                      </select>
+                    </div>
+                  </div>
+                  <div className="flex items-center gap-2">
+                    <span className="text-sm text-bambu-gray">
+                      {t('archives.log.page')} {Math.floor(logOffset / logPageSize) + 1} / {Math.max(1, Math.ceil(printLogData.total / logPageSize))}
+                    </span>
+                    <Button variant="secondary" size="sm" onClick={() => setLogOffset(Math.max(0, logOffset - logPageSize))} disabled={logOffset === 0}>
+                      <ChevronLeft className="w-4 h-4" />
+                    </Button>
+                    <Button variant="secondary" size="sm" onClick={() => setLogOffset(logOffset + logPageSize)} disabled={logOffset + logPageSize >= printLogData.total}>
+                      <ChevronRight className="w-4 h-4" />
+                    </Button>
+                  </div>
+                </div>
+              </>
+            )}
+          </Card>
+
+        </div>
       ) : null}
       ) : null}
 
 
       {/* Upload Modal */}
       {/* Upload Modal */}
@@ -3019,6 +3331,21 @@ export function ArchivesPage() {
       {showTagManagement && (
       {showTagManagement && (
         <TagManagementModal onClose={() => setShowTagManagement(false)} />
         <TagManagementModal onClose={() => setShowTagManagement(false)} />
       )}
       )}
+
+      {/* Clear Log Confirmation */}
+      {showClearLogConfirm && (
+        <ConfirmModal
+          title={t('archives.log.clearLogTitle')}
+          message={t('archives.log.clearLogConfirm')}
+          confirmText={t('archives.log.clearLogButton')}
+          variant="danger"
+          onConfirm={() => {
+            clearLogMutation.mutate();
+            setShowClearLogConfirm(false);
+          }}
+          onCancel={() => setShowClearLogConfirm(false)}
+        />
+      )}
     </div>
     </div>
   );
   );
 }
 }

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


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
 
     <!-- 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-BwlOtO_N.js"></script>
+    <script type="module" crossorigin src="/assets/index-BpibLMBb.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-OqmBOPoC.css">
     <link rel="stylesheet" crossorigin href="/assets/index-OqmBOPoC.css">
   </head>
   </head>
   <body>
   <body>

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