Browse Source

feat: add print log timeline view in archives page

Add a chronological, table-based print log as a 4th view mode in the
Archives page. Log entries are stored in a separate print_log_entries
table — clearing the log never touches archives or queue items.

Backend:
- New PrintLogEntry model with independent table
- GET /print-log/ endpoint with search, printer, user, status, date filters
- DELETE /print-log/ clears only log entries
- Thumbnail serving endpoint for log entries
- write_log_entry() service called on print completion
- Auth: ARCHIVES_READ for viewing, ARCHIVES_DELETE_ALL for clearing

Frontend:
- New 'log' ViewMode with ClipboardList icon toggle
- Filterable/searchable table with pagination (10/25/50/100 rows)
- Colored status badges, duration formatting, filament color swatches
- Clear button with confirmation modal
- All filter state persisted to localStorage
- i18n: EN, DE, JA, FR, IT translations
maziggy 3 months ago
parent
commit
27cecbed87

+ 1 - 0
CHANGELOG.md

@@ -15,6 +15,7 @@ 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).
 - **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.
+- **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
 - **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.

+ 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)
 - Archive comparison (side-by-side diff)
 - 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
 - Real-time printer status via WebSocket

+ 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,
         orca_base_cache,
         pending_upload,
+        print_log,
         print_queue,
         printer,
         project,

+ 35 - 0
backend/app/main.py

@@ -194,6 +194,7 @@ from backend.app.api.routes import (
     notification_templates,
     notifications,
     pending_uploads,
+    print_log,
     print_queue,
     printers,
     projects,
@@ -1958,6 +1959,9 @@ async def on_print_complete(printer_id: int, data: dict):
     except Exception as 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)
     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")
 
+    # 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)
     usage_results: list[dict] = []
     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(local_presets.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(kprofiles.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

+ 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;
 }
 
+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 {
   total_prints: number;
   successful_prints: number;
@@ -2954,6 +2976,32 @@ export const api = {
     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
   getSettings: () => request<AppSettings>('/settings/'),
   updateSettings: (data: AppSettingsUpdate) =>

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

@@ -484,6 +484,7 @@ export default {
     gridView: 'Rasteransicht',
     listView: 'Listenansicht',
     calendarView: 'Kalenderansicht',
+    logView: 'Druckprotokoll',
     manageTags: 'Tags verwalten',
     showFailedPrints: 'Fehlgeschlagene Drucke anzeigen',
     hideFailedPrints: 'Fehlgeschlagene Drucke ausblenden',
@@ -672,6 +673,34 @@ export default {
       actions: 'Aktionen',
       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

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

@@ -484,6 +484,7 @@ export default {
     gridView: 'Grid view',
     listView: 'List view',
     calendarView: 'Calendar view',
+    logView: 'Print Log',
     manageTags: 'Manage Tags',
     showFailedPrints: 'Show failed prints',
     hideFailedPrints: 'Hide failed prints',
@@ -672,6 +673,34 @@ export default {
       actions: 'Actions',
       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

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

@@ -484,6 +484,7 @@ export default {
     gridView: 'Grille',
     listView: 'Liste',
     calendarView: 'Calendrier',
+    logView: 'Journal d\'impression',
     manageTags: 'Gérer les tags',
     showFailedPrints: 'Afficher les échecs',
     hideFailedPrints: 'Masquer les échecs',
@@ -672,6 +673,34 @@ export default {
       actions: 'Actions',
       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

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

@@ -475,6 +475,7 @@ export default {
     gridView: 'Vista griglia',
     listView: 'Vista elenco',
     calendarView: 'Vista calendario',
+    logView: 'Registro stampe',
     manageTags: 'Gestisci tag',
     showFailedPrints: 'Mostra stampe fallite',
     hideFailedPrints: 'Nascondi stampe fallite',
@@ -663,6 +664,34 @@ export default {
       actions: 'Azioni',
       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

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

@@ -484,6 +484,7 @@ export default {
     gridView: 'グリッド表示',
     listView: 'リスト表示',
     calendarView: 'カレンダー表示',
+    logView: '印刷ログ',
     showFailedPrints: '失敗した印刷を表示',
     hideFailedPrints: '失敗した印刷を非表示',
     printTime: '印刷時間',
@@ -677,6 +678,34 @@ export default {
       date: '日付',
       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: '利用可能なプリンターがありません',
     archiveOrReprint: 'アーカイブまたは再印刷',
     multiPrinterPrint: 'マルチプリンター印刷',

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

@@ -46,6 +46,7 @@ import {
   ChevronRight,
   Settings,
   User,
+  ClipboardList,
 } from 'lucide-react';
 import { api } from '../api/client';
 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 ViewMode = 'grid' | 'list' | 'calendar';
+type ViewMode = 'grid' | 'list' | 'calendar' | 'log';
 type Collection = 'all' | 'recent' | 'this-week' | 'this-month' | 'favorites' | 'failed' | 'duplicates';
 
 const collections: { id: Collection; label: string; icon: React.ReactNode }[] = [
@@ -2133,6 +2134,29 @@ export function ArchivesPage() {
   const [showTagManagement, setShowTagManagement] = useState(false);
   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
   useEffect(() => {
     if (highlightedArchiveId) {
@@ -2173,6 +2197,27 @@ export function ArchivesPage() {
     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 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
   useEffect(() => {
     if (filterPrinter !== null) {
@@ -2248,6 +2305,47 @@ export function ArchivesPage() {
     localStorage.setItem('archiveCollection', 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]) || []);
 
   // Extract unique materials and colors from archives
@@ -2672,8 +2770,40 @@ export function ArchivesPage() {
         </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">
           <div className="flex flex-col md:flex-row gap-3 md:gap-4 md:items-center md:flex-wrap">
             {/* Search - full width on mobile */}
@@ -2799,29 +2929,6 @@ export function ArchivesPage() {
                 <option value="size-asc">{t('archives.sortSmallest')}</option>
               </select>
             </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>
             {hasTopFilters && (
               <Button
@@ -2879,7 +2986,7 @@ export function ArchivesPage() {
             </div>
           )}
         </CardContent>
-      </Card>
+      </Card>}
 
       {/* Pending Uploads Panel (visible when in queue mode with pending files) */}
       <PendingUploadsPanel />
@@ -2958,6 +3065,211 @@ export function ArchivesPage() {
             ))}
           </div>
         </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}
 
       {/* Upload Modal */}
@@ -3019,6 +3331,21 @@ export function ArchivesPage() {
       {showTagManagement && (
         <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>
   );
 }

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


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <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-BKB_4Fk2.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-OqmBOPoC.css">
   </head>
   <body>

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