Browse Source

fix(stats): per-event aggregation so reprints add to Quick Stats instead of overwriting (#1378)

  Statistics now aggregate over PrintLogEntry (one row per print event,
  the same table backing the global Print Log) rather than PrintArchive
  (one row per file). A reprint creates a new PrintLogEntry instead of
  overwriting the source archive's runtime fields, so:

  - a 100 g successful print + a 10 g failed reprint correctly sums to
    110 g / 2 prints / 1 successful / 1 failed in Quick Stats and the
    Prometheus /metrics endpoint (previously the failed reprint silently
    replaced the source archive's data; totals dropped from 100 g to 10 g)
  - the archive's card cost/energy_kwh are preserved on reprints (only
    the first run writes them); per-run actuals live on PrintLogEntry
  - failed/cancelled/stopped reprints record partial-aware filament: sum
    of tracked spool deltas when inventory is set up, else estimate
    scaled to progress%, else None — prevents the full slicer estimate
    from inflating totals on a print that stopped at 10 % progress

  PrintLogEntry gains six columns: archive_id (nullable FK, ON DELETE
  SET NULL so log entries survive archive deletion preserving #1343
  soft-delete-vs-stats decoupling), cost, energy_kwh, energy_cost,
  failure_reason, created_by_id. Idempotent SQLite + Postgres migrations.

  New per-archive surface:

  - archive list response carries run_count / last_run_at /
    total_filament_actual_grams / successful_run_count / failed_run_count
    via a single batch JOIN, no N+1
  - new GET /archives/{id}/runs endpoint returns every PrintLogEntry for
    the archive (ARCHIVES_READ permission, newest-first ordering)
  - archive cards render an orange "N prints" badge for archives with
    more than one run; clicking the badge opens a dedicated PrintLogModal
    with date/status/duration/filament/cost columns plus failure_reason
    under failed runs. Also reachable via the context menu's new "Print
    Log" entry (works for single-run archives too), and embedded at the
    top of the Edit Archive modal for context.

  The purge_stats=true delete path now hard-deletes linked PrintLogEntry
  rows up front so the archive's contribution truly leaves the totals;
  without it, ON DELETE SET NULL would orphan the runs and leave them
  counting toward stats.
maziggy 1 week ago
parent
commit
856b849ffa

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 1 - 0
README.md

@@ -127,6 +127,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 - 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)
+- **Per-archive print history** — Each archive card shows an `N prints` badge whenever a model has been printed more than once (reprint + failed retries all counted). Click the badge for the full per-archive Print Log — every individual run with date, status, duration, filament used, cost, and failure reason. Reprints contribute new rows so a failed retry never overwrites the source archive's data — the original 100 g successful print stays visible alongside the 10 g failed reprint, and Quick Stats add up to 110 g across both events.
 - **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.
 - **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

+ 174 - 69
backend/app/api/routes/archives.py

@@ -10,7 +10,7 @@ from pathlib import Path
 
 
 from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
 from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
 from fastapi.responses import FileResponse, Response
 from fastapi.responses import FileResponse, Response
-from sqlalchemy import and_, func, or_, select
+from sqlalchemy import and_, case, func, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.auth import (
 from backend.app.core.auth import (
@@ -26,6 +26,7 @@ from backend.app.models.filament import Filament
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate, ReprintRequest
 from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate, ReprintRequest
+from backend.app.schemas.print_log import PrintLogResponse
 from backend.app.schemas.slicer import SliceRequest
 from backend.app.schemas.slicer import SliceRequest
 from backend.app.services.archive import ArchiveService
 from backend.app.services.archive import ArchiveService
 from backend.app.utils.http import build_content_disposition
 from backend.app.utils.http import build_content_disposition
@@ -136,6 +137,17 @@ def _apply_user_filter(conditions: list, created_by_id: int | None):
             conditions.append(PrintArchive.created_by_id == created_by_id)
             conditions.append(PrintArchive.created_by_id == created_by_id)
 
 
 
 
+def _apply_run_user_filter(conditions: list, created_by_id: int | None):
+    """Append created_by_id filter scoped to PrintLogEntry rows."""
+    from backend.app.models.print_log import PrintLogEntry
+
+    if created_by_id is not None:
+        if created_by_id == -1:
+            conditions.append(PrintLogEntry.created_by_id.is_(None))
+        else:
+            conditions.append(PrintLogEntry.created_by_id == created_by_id)
+
+
 def compute_time_accuracy(archive: PrintArchive) -> dict:
 def compute_time_accuracy(archive: PrintArchive) -> dict:
     """Compute actual print time and accuracy for an archive.
     """Compute actual print time and accuracy for an archive.
 
 
@@ -163,12 +175,48 @@ def compute_time_accuracy(archive: PrintArchive) -> dict:
     return result
     return result
 
 
 
 
+async def _load_run_aggregates(db: AsyncSession, archive_ids: list[int]) -> dict[int, dict]:
+    """Batch-load per-archive run aggregates from PrintLogEntry.
+
+    Returns ``{archive_id: {run_count, last_run_at, total_filament_actual_grams,
+    successful_run_count, failed_run_count}}``. Archives with no logged runs are
+    absent from the map; callers should treat that as zero/none.
+    """
+    from backend.app.models.print_log import PrintLogEntry
+
+    if not archive_ids:
+        return {}
+    rows = await db.execute(
+        select(
+            PrintLogEntry.archive_id,
+            func.count(PrintLogEntry.id).label("run_count"),
+            func.max(PrintLogEntry.started_at).label("last_run_at"),
+            func.coalesce(func.sum(PrintLogEntry.filament_used_grams), 0).label("total_filament"),
+            func.sum(case((PrintLogEntry.status == "completed", 1), else_=0)).label("successful"),
+            func.sum(case((PrintLogEntry.status == "failed", 1), else_=0)).label("failed"),
+        )
+        .where(PrintLogEntry.archive_id.in_(archive_ids))
+        .group_by(PrintLogEntry.archive_id)
+    )
+    aggregates: dict[int, dict] = {}
+    for archive_id, run_count, last_run_at, total_filament, successful, failed in rows.all():
+        aggregates[archive_id] = {
+            "run_count": int(run_count or 0),
+            "last_run_at": last_run_at,
+            "total_filament_actual_grams": float(total_filament) if total_filament else None,
+            "successful_run_count": int(successful or 0),
+            "failed_run_count": int(failed or 0),
+        }
+    return aggregates
+
+
 def archive_to_response(
 def archive_to_response(
     archive: PrintArchive,
     archive: PrintArchive,
     duplicates: list[dict] | None = None,
     duplicates: list[dict] | None = None,
     duplicate_count: int = 0,
     duplicate_count: int = 0,
     duplicate_sequence: int = 0,
     duplicate_sequence: int = 0,
     original_archive_id: int | None = None,
     original_archive_id: int | None = None,
+    run_aggregate: dict | None = None,
 ) -> dict:
 ) -> dict:
     """Convert archive model to response dict with computed fields."""
     """Convert archive model to response dict with computed fields."""
     data = {
     data = {
@@ -226,6 +274,13 @@ def archive_to_response(
     accuracy_data = compute_time_accuracy(archive)
     accuracy_data = compute_time_accuracy(archive)
     data.update(accuracy_data)
     data.update(accuracy_data)
 
 
+    if run_aggregate:
+        data["run_count"] = run_aggregate.get("run_count", 0)
+        data["last_run_at"] = run_aggregate.get("last_run_at")
+        data["total_filament_actual_grams"] = run_aggregate.get("total_filament_actual_grams")
+        data["successful_run_count"] = run_aggregate.get("successful_run_count", 0)
+        data["failed_run_count"] = run_aggregate.get("failed_run_count", 0)
+
     return data
     return data
 
 
 
 
@@ -318,6 +373,8 @@ async def list_archives(
             for sequence, (archive_id, _) in enumerate(group):
             for sequence, (archive_id, _) in enumerate(group):
                 duplicate_meta_by_archive_id.setdefault(archive_id, (sequence, original_id, duplicate_count))
                 duplicate_meta_by_archive_id.setdefault(archive_id, (sequence, original_id, duplicate_count))
 
 
+    run_aggregates = await _load_run_aggregates(db, [a.id for a in archives])
+
     # Build response with duplicate sequence and original archive ID pre-computed
     # Build response with duplicate sequence and original archive ID pre-computed
     result = []
     result = []
     for a in archives:
     for a in archives:
@@ -342,6 +399,7 @@ async def list_archives(
                 duplicate_count=duplicate_count,
                 duplicate_count=duplicate_count,
                 duplicate_sequence=duplicate_sequence,
                 duplicate_sequence=duplicate_sequence,
                 original_archive_id=original_archive_id,
                 original_archive_id=original_archive_id,
+                run_aggregate=run_aggregates.get(a.id),
             )
             )
         )
         )
     return result
     return result
@@ -762,69 +820,75 @@ async def get_archive_stats(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
 ):
 ):
-    """Get statistics across all archives."""
+    """Get statistics across all archives.
+
+    Stats aggregate over PrintLogEntry (one row per print event), not over
+    PrintArchive (one row per file). A reprint contributes a new PrintLogEntry
+    so its filament/cost/time/energy add to the totals instead of overwriting
+    the source archive's first-run values (#1378).
+    """
+    from backend.app.models.print_log import PrintLogEntry
+
     _validate_user_filter_permission(current_user, created_by_id)
     _validate_user_filter_permission(current_user, created_by_id)
 
 
-    # Build date filter conditions
+    # Build date filter conditions scoped to PrintLogEntry (event-time).
     base_conditions = []
     base_conditions = []
     if date_from:
     if date_from:
         dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
         dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
-        base_conditions.append(PrintArchive.created_at >= dt_from)
+        base_conditions.append(PrintLogEntry.created_at >= dt_from)
     if date_to:
     if date_to:
         dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
         dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
-        base_conditions.append(PrintArchive.created_at <= dt_to)
-    _apply_user_filter(base_conditions, created_by_id)
+        base_conditions.append(PrintLogEntry.created_at <= dt_to)
+    _apply_run_user_filter(base_conditions, created_by_id)
 
 
-    # Total counts
-    total_result = await db.execute(select(func.count(PrintArchive.id)).where(*base_conditions))
+    # Total counts (one row per print event).
+    total_result = await db.execute(select(func.count(PrintLogEntry.id)).where(*base_conditions))
     total_prints = total_result.scalar() or 0
     total_prints = total_result.scalar() or 0
 
 
     successful_result = await db.execute(
     successful_result = await db.execute(
-        select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed", *base_conditions)
+        select(func.count(PrintLogEntry.id)).where(PrintLogEntry.status == "completed", *base_conditions)
     )
     )
     successful_prints = successful_result.scalar() or 0
     successful_prints = successful_result.scalar() or 0
 
 
     failed_result = await db.execute(
     failed_result = await db.execute(
-        select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed", *base_conditions)
+        select(func.count(PrintLogEntry.id)).where(PrintLogEntry.status == "failed", *base_conditions)
     )
     )
     failed_prints = failed_result.scalar() or 0
     failed_prints = failed_result.scalar() or 0
 
 
-    # Totals - use actual print time from timestamps (not slicer estimates)
-    # For archives with both started_at and completed_at, calculate actual duration
-    # Fall back to print_time_seconds only for archives without timestamps
-    archives_for_time = await db.execute(
-        select(PrintArchive.started_at, PrintArchive.completed_at, PrintArchive.print_time_seconds).where(
-            *base_conditions
-        )
+    # Total elapsed time — PrintLogEntry stores duration_seconds directly so we
+    # can sum it server-side. Rows missing duration fall back to the slicer
+    # estimate from the archive (joined for that case only).
+    time_rows = await db.execute(
+        select(
+            PrintLogEntry.duration_seconds,
+            PrintLogEntry.started_at,
+            PrintLogEntry.completed_at,
+        ).where(*base_conditions)
     )
     )
     total_seconds = 0
     total_seconds = 0
-    for started_at, completed_at, print_time_seconds in archives_for_time.all():
-        if started_at and completed_at:
-            # Use actual elapsed time
-            actual_seconds = (completed_at - started_at).total_seconds()
-            if actual_seconds > 0:
-                total_seconds += actual_seconds
-        elif print_time_seconds:
-            # Fallback to estimate only if no timestamps
-            total_seconds += print_time_seconds
+    for duration_seconds, started_at, completed_at in time_rows.all():
+        if duration_seconds:
+            total_seconds += duration_seconds
+        elif started_at and completed_at:
+            elapsed = (completed_at - started_at).total_seconds()
+            if elapsed > 0:
+                total_seconds += int(elapsed)
     total_time = total_seconds / 3600  # Convert to hours
     total_time = total_seconds / 3600  # Convert to hours
 
 
-    # Sum filament directly - filament_used_grams already contains the total for the print job
     filament_result = await db.execute(
     filament_result = await db.execute(
-        select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)).where(*base_conditions)
+        select(func.coalesce(func.sum(PrintLogEntry.filament_used_grams), 0)).where(*base_conditions)
     )
     )
     total_filament = filament_result.scalar() or 0
     total_filament = filament_result.scalar() or 0
 
 
-    cost_result = await db.execute(select(func.sum(PrintArchive.cost)).where(*base_conditions))
+    cost_result = await db.execute(select(func.sum(PrintLogEntry.cost)).where(*base_conditions))
     total_cost = cost_result.scalar() or 0
     total_cost = cost_result.scalar() or 0
 
 
     # By filament type (split comma-separated values for multi-material prints)
     # By filament type (split comma-separated values for multi-material prints)
     filament_type_result = await db.execute(
     filament_type_result = await db.execute(
-        select(PrintArchive.filament_type).where(PrintArchive.filament_type.isnot(None), *base_conditions)
+        select(PrintLogEntry.filament_type).where(PrintLogEntry.filament_type.isnot(None), *base_conditions)
     )
     )
     prints_by_filament: dict[str, int] = {}
     prints_by_filament: dict[str, int] = {}
     for (filament_types,) in filament_type_result.all():
     for (filament_types,) in filament_type_result.all():
-        # Split by comma and count each type
         for ftype in filament_types.split(","):
         for ftype in filament_types.split(","):
             ftype = ftype.strip()
             ftype = ftype.strip()
             if ftype:
             if ftype:
@@ -832,47 +896,49 @@ async def get_archive_stats(
 
 
     # By printer
     # By printer
     printer_result = await db.execute(
     printer_result = await db.execute(
-        select(PrintArchive.printer_id, func.count(PrintArchive.id))
+        select(PrintLogEntry.printer_id, func.count(PrintLogEntry.id))
         .where(*base_conditions)
         .where(*base_conditions)
-        .group_by(PrintArchive.printer_id)
+        .group_by(PrintLogEntry.printer_id)
     )
     )
     prints_by_printer = {str(k): v for k, v in printer_result.all()}
     prints_by_printer = {str(k): v for k, v in printer_result.all()}
 
 
-    # Time accuracy statistics
-    # Get all completed archives with both estimated and actual times
-    accuracy_result = await db.execute(
-        select(PrintArchive)
-        .where(PrintArchive.status == "completed", *base_conditions)
-        .where(PrintArchive.print_time_seconds.isnot(None))
-        .where(PrintArchive.started_at.isnot(None))
-        .where(PrintArchive.completed_at.isnot(None))
+    # Time accuracy — compare each completed run's actual duration to the
+    # slicer's estimate on the linked archive. Runs without a linked archive
+    # (NULL archive_id) or without an estimate are excluded.
+    accuracy_rows = await db.execute(
+        select(
+            PrintLogEntry.duration_seconds,
+            PrintLogEntry.started_at,
+            PrintLogEntry.completed_at,
+            PrintLogEntry.printer_id,
+            PrintArchive.print_time_seconds,
+        )
+        .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
+        .where(
+            PrintLogEntry.status == "completed",
+            PrintArchive.print_time_seconds.isnot(None),
+            *base_conditions,
+        )
     )
     )
-    archives_with_times = list(accuracy_result.scalars().all())
-
     average_accuracy = None
     average_accuracy = None
     accuracy_by_printer: dict[str, float] = {}
     accuracy_by_printer: dict[str, float] = {}
-
-    if archives_with_times:
-        accuracies = []
-        printer_accuracies: dict[str, list[float]] = {}
-
-        for archive in archives_with_times:
-            acc_data = compute_time_accuracy(archive)
-            if acc_data["time_accuracy"] is not None:
-                accuracies.append(acc_data["time_accuracy"])
-
-                # Group by printer
-                printer_key = str(archive.printer_id) if archive.printer_id else "unknown"
-                if printer_key not in printer_accuracies:
-                    printer_accuracies[printer_key] = []
-                printer_accuracies[printer_key].append(acc_data["time_accuracy"])
-
-        if accuracies:
-            average_accuracy = round(sum(accuracies) / len(accuracies), 1)
-
-        # Calculate per-printer averages
-        for printer_key, accs in printer_accuracies.items():
-            accuracy_by_printer[printer_key] = round(sum(accs) / len(accs), 1)
+    accuracies: list[float] = []
+    printer_accuracies: dict[str, list[float]] = {}
+    for duration_seconds, started_at, completed_at, run_printer_id, estimate_seconds in accuracy_rows.all():
+        actual_seconds = duration_seconds
+        if not actual_seconds and started_at and completed_at:
+            elapsed = (completed_at - started_at).total_seconds()
+            actual_seconds = int(elapsed) if elapsed > 0 else None
+        if not actual_seconds or not estimate_seconds:
+            continue
+        accuracy = (estimate_seconds / actual_seconds) * 100
+        accuracies.append(accuracy)
+        printer_key = str(run_printer_id) if run_printer_id else "unknown"
+        printer_accuracies.setdefault(printer_key, []).append(accuracy)
+    if accuracies:
+        average_accuracy = round(sum(accuracies) / len(accuracies), 1)
+    for printer_key, accs in printer_accuracies.items():
+        accuracy_by_printer[printer_key] = round(sum(accs) / len(accs), 1)
 
 
     # Energy totals - check which mode to use
     # Energy totals - check which mode to use
     from backend.app.api.routes.settings import get_setting
     from backend.app.api.routes.settings import get_setting
@@ -899,11 +965,11 @@ async def get_archive_stats(
         )
         )
         total_energy_cost = total_energy_kwh * energy_cost_per_kwh
         total_energy_cost = total_energy_kwh * energy_cost_per_kwh
     else:
     else:
-        # Per-print mode: sum the per-print energy column directly.
-        energy_kwh_result = await db.execute(select(func.sum(PrintArchive.energy_kwh)).where(*base_conditions))
+        # Per-print mode: sum the per-run energy column from PrintLogEntry.
+        energy_kwh_result = await db.execute(select(func.sum(PrintLogEntry.energy_kwh)).where(*base_conditions))
         total_energy_kwh = energy_kwh_result.scalar() or 0
         total_energy_kwh = energy_kwh_result.scalar() or 0
 
 
-        energy_cost_result = await db.execute(select(func.sum(PrintArchive.energy_cost)).where(*base_conditions))
+        energy_cost_result = await db.execute(select(func.sum(PrintLogEntry.energy_cost)).where(*base_conditions))
         total_energy_cost = energy_cost_result.scalar() or 0
         total_energy_cost = energy_cost_result.scalar() or 0
 
 
     return ArchiveStats(
     return ArchiveStats(
@@ -1178,7 +1244,35 @@ async def get_archive(
         print_name=archive.print_name,
         print_name=archive.print_name,
         makerworld_model_id=makerworld_id,
         makerworld_model_id=makerworld_id,
     )
     )
-    return archive_to_response(archive, duplicates)
+    run_aggregates = await _load_run_aggregates(db, [archive.id])
+    return archive_to_response(archive, duplicates, run_aggregate=run_aggregates.get(archive.id))
+
+
+@router.get("/{archive_id}/runs", response_model=PrintLogResponse)
+async def list_archive_runs(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
+    """List PrintLogEntry rows for this archive — one per print event.
+
+    Newest first. Drives the per-archive "Print Log" view (#1378).
+    """
+    from backend.app.models.print_log import PrintLogEntry
+    from backend.app.schemas.print_log import PrintLogEntrySchema
+
+    archive = await db.get(PrintArchive, archive_id)
+    if not archive or archive.deleted_at is not None:
+        raise HTTPException(404, "Archive not found")
+
+    rows = await db.execute(
+        select(PrintLogEntry)
+        .where(PrintLogEntry.archive_id == archive_id)
+        .order_by(PrintLogEntry.started_at.desc().nulls_last(), PrintLogEntry.id.desc())
+    )
+    entries = list(rows.scalars().all())
+    items = [PrintLogEntrySchema.model_validate(e, from_attributes=True) for e in entries]
+    return PrintLogResponse(items=items, total=len(items))
 
 
 
 
 @router.get("/{archive_id}/similar")
 @router.get("/{archive_id}/similar")
@@ -1571,6 +1665,17 @@ async def delete_archive(
 
 
     service = ArchiveService(db)
     service = ArchiveService(db)
     if purge_stats:
     if purge_stats:
+        # Hard-delete the linked PrintLogEntry rows first so their filament /
+        # cost / count contributions disappear from /archives/stats. The FK is
+        # ON DELETE SET NULL, so without this delete the runs would survive
+        # the archive row and keep showing up in totals (#1343 / #1378).
+        from sqlalchemy import delete as sa_delete
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        await db.execute(sa_delete(PrintLogEntry).where(PrintLogEntry.archive_id == archive_id))
+        await db.commit()
+
         if not await service.delete_archive(archive_id):
         if not await service.delete_archive(archive_id):
             raise HTTPException(404, "Archive not found")
             raise HTTPException(404, "Archive not found")
         return {"status": "deleted", "purged_from_stats": True}
         return {"status": "deleted", "purged_from_stats": True}

+ 10 - 8
backend/app/api/routes/metrics.py

@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.config import APP_VERSION
 from backend.app.core.config import APP_VERSION
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
-from backend.app.models.archive import PrintArchive
+from backend.app.models.print_log import PrintLogEntry
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
@@ -352,11 +352,13 @@ async def get_metrics(
     # Print statistics (from database)
     # Print statistics (from database)
     # =========================================================================
     # =========================================================================
 
 
-    # Total prints by status
+    # Total prints by status — count print events from PrintLogEntry so
+    # reprints contribute new rows instead of overwriting the source archive
+    # (#1378).
     lines.append("")
     lines.append("")
     lines.append("# HELP bambuddy_prints_total Total number of prints by result")
     lines.append("# HELP bambuddy_prints_total Total number of prints by result")
     lines.append("# TYPE bambuddy_prints_total counter")
     lines.append("# TYPE bambuddy_prints_total counter")
-    result = await db.execute(select(PrintArchive.status, func.count(PrintArchive.id)).group_by(PrintArchive.status))
+    result = await db.execute(select(PrintLogEntry.status, func.count(PrintLogEntry.id)).group_by(PrintLogEntry.status))
     for print_result, count in result.all():
     for print_result, count in result.all():
         result_label = print_result or "unknown"
         result_label = print_result or "unknown"
         labels = format_labels(result=result_label)
         labels = format_labels(result=result_label)
@@ -367,7 +369,7 @@ async def get_metrics(
     lines.append("# HELP bambuddy_printer_prints_total Total prints per printer")
     lines.append("# HELP bambuddy_printer_prints_total Total prints per printer")
     lines.append("# TYPE bambuddy_printer_prints_total counter")
     lines.append("# TYPE bambuddy_printer_prints_total counter")
     result = await db.execute(
     result = await db.execute(
-        select(PrintArchive.printer_id, func.count(PrintArchive.id)).group_by(PrintArchive.printer_id)
+        select(PrintLogEntry.printer_id, func.count(PrintLogEntry.id)).group_by(PrintLogEntry.printer_id)
     )
     )
     for printer_id, count in result.all():
     for printer_id, count in result.all():
         if printer_id and printer_id in printer_info:
         if printer_id and printer_id in printer_info:
@@ -379,19 +381,19 @@ async def get_metrics(
             )
             )
             lines.append(f"bambuddy_printer_prints_total{labels} {count}")
             lines.append(f"bambuddy_printer_prints_total{labels} {count}")
 
 
-    # Total filament used - filament_used_grams already contains the total for each print job
+    # Total filament used — sum per-run actuals from PrintLogEntry.
     lines.append("")
     lines.append("")
     lines.append("# HELP bambuddy_filament_used_grams Total filament used in grams")
     lines.append("# HELP bambuddy_filament_used_grams Total filament used in grams")
     lines.append("# TYPE bambuddy_filament_used_grams counter")
     lines.append("# TYPE bambuddy_filament_used_grams counter")
-    result = await db.execute(select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)))
+    result = await db.execute(select(func.coalesce(func.sum(PrintLogEntry.filament_used_grams), 0)))
     total_filament = result.scalar() or 0
     total_filament = result.scalar() or 0
     lines.append(f"bambuddy_filament_used_grams {total_filament:.1f}")
     lines.append(f"bambuddy_filament_used_grams {total_filament:.1f}")
 
 
-    # Total print time
+    # Total print time — sum per-run elapsed durations.
     lines.append("")
     lines.append("")
     lines.append("# HELP bambuddy_print_time_seconds Total print time in seconds")
     lines.append("# HELP bambuddy_print_time_seconds Total print time in seconds")
     lines.append("# TYPE bambuddy_print_time_seconds counter")
     lines.append("# TYPE bambuddy_print_time_seconds counter")
-    result = await db.execute(select(func.coalesce(func.sum(PrintArchive.print_time_seconds), 0)))
+    result = await db.execute(select(func.coalesce(func.sum(PrintLogEntry.duration_seconds), 0)))
     total_time = result.scalar() or 0
     total_time = result.scalar() or 0
     lines.append(f"bambuddy_print_time_seconds {total_time}")
     lines.append(f"bambuddy_print_time_seconds {total_time}")
 
 

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

@@ -2498,6 +2498,27 @@ async def run_migrations(conn):
             llt_n,
             llt_n,
         )
         )
 
 
+    # Migration: extend print_log_entries with archive_id, cost, energy, failure_reason,
+    # created_by_id (#1378). Statistics queries shift from PrintArchive to PrintLogEntry
+    # so reprints contribute new rows instead of overwriting the source archive's data.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN archive_id INTEGER")
+        await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN cost REAL")
+        await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN energy_kwh REAL")
+        await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN energy_cost REAL")
+        await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN failure_reason VARCHAR(100)")
+        await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN created_by_id INTEGER")
+    else:
+        await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN IF NOT EXISTS archive_id INTEGER")
+        await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN IF NOT EXISTS cost DOUBLE PRECISION")
+        await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN IF NOT EXISTS energy_kwh DOUBLE PRECISION")
+        await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN IF NOT EXISTS energy_cost DOUBLE PRECISION")
+        await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN IF NOT EXISTS failure_reason VARCHAR(100)")
+        await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN IF NOT EXISTS created_by_id INTEGER")
+    await _safe_execute(
+        conn, "CREATE INDEX IF NOT EXISTS ix_print_log_entries_archive_id ON print_log_entries (archive_id)"
+    )
+
 
 
 async def seed_notification_templates():
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""
     """Seed default notification templates if they don't exist."""

+ 92 - 5
backend/app/main.py

@@ -587,6 +587,36 @@ def register_expected_print(
     )
     )
 
 
 
 
+def _compute_run_filament_grams(
+    status: str,
+    archive_filament_used_grams: float | None,
+    progress: float | int | None,
+    usage_results: list[dict] | None,
+) -> float | None:
+    """Per-run filament for PrintLogEntry, partial-aware (#1378).
+
+    For ``completed``: returns the archive's slicer estimate (which approximates
+    actual since the print finished). For failed / cancelled / stopped:
+        1. Sum of tracked spool deltas in ``usage_results`` (most accurate
+           when inventory is configured for the print).
+        2. ``estimate * progress%`` (when no inventory delta available).
+        3. ``None`` (no signal at all — e.g. progress=0 and no spool data).
+    """
+    if status == "completed":
+        return archive_filament_used_grams
+
+    tracked_grams = sum(r.get("weight_used") or 0 for r in (usage_results or []))
+    if tracked_grams > 0:
+        return round(tracked_grams, 1)
+
+    if archive_filament_used_grams:
+        scale = max(0.0, min(((progress or 0) / 100.0), 1.0))
+        if scale > 0:
+            return round(archive_filament_used_grams * scale, 1)
+
+    return None
+
+
 def _get_start_ams_mapping(data: dict, archive_id: int | None) -> list[int] | None:
 def _get_start_ams_mapping(data: dict, archive_id: int | None) -> list[int] | None:
     """Resolve AMS mapping for print start without consuming stored queue/reprint state."""
     """Resolve AMS mapping for print start without consuming stored queue/reprint state."""
     stored_ams_mapping = data.get("ams_mapping")
     stored_ams_mapping = data.get("ams_mapping")
@@ -3545,9 +3575,33 @@ async def on_print_complete(printer_id: int, data: dict):
                 if archive.created_by_id is None and _print_user_id is not None:
                 if archive.created_by_id is None and _print_user_id is not None:
                     archive.created_by_id = _print_user_id
                     archive.created_by_id = _print_user_id
                 p_info = printer_manager.get_printer(printer_id)
                 p_info = printer_manager.get_printer(printer_id)
+                # Per-run actuals — written to PrintLogEntry so stats reflect
+                # what THIS print actually used, not the source archive's
+                # first-run values (#1378). Helper handles the partial-print
+                # math (failed / cancelled / stopped get scaled to progress
+                # or to tracked spool deltas).
+                _run_status = data.get("status", "completed")
+                _run_grams = _compute_run_filament_grams(
+                    _run_status,
+                    archive.filament_used_grams,
+                    data.get("progress"),
+                    usage_results,
+                )
+
+                # Per-run cost — prefer usage_results sum. For partial prints
+                # we deliberately skip the topup-to-estimate logic in
+                # usage_tracker (which assumes the print completed); the raw
+                # tracked-spool sum is closer to what THIS run actually cost.
+                _run_cost: float | None = None
+                if usage_results:
+                    _run_cost = sum(r.get("cost") or 0 for r in usage_results) or None
+                if _run_cost is None and _run_status == "completed":
+                    _run_cost = archive.cost
+
                 await write_log_entry(
                 await write_log_entry(
                     db,
                     db,
-                    status=data.get("status", "completed"),
+                    archive_id=archive.id,
+                    status=_run_status,
                     print_name=archive.print_name,
                     print_name=archive.print_name,
                     printer_name=p_info.name if p_info else None,
                     printer_name=p_info.name if p_info else None,
                     printer_id=printer_id,
                     printer_id=printer_id,
@@ -3555,8 +3609,11 @@ async def on_print_complete(printer_id: int, data: dict):
                     completed_at=archive.completed_at,
                     completed_at=archive.completed_at,
                     filament_type=archive.filament_type,
                     filament_type=archive.filament_type,
                     filament_color=archive.filament_color,
                     filament_color=archive.filament_color,
-                    filament_used_grams=archive.filament_used_grams,
+                    filament_used_grams=_run_grams,
+                    cost=_run_cost,
+                    failure_reason=archive.failure_reason,
                     thumbnail_path=archive.thumbnail_path,
                     thumbnail_path=archive.thumbnail_path,
+                    created_by_id=archive.created_by_id,
                     created_by_username=_print_user_info.get("username") if _print_user_info else None,
                     created_by_username=_print_user_info.get("username") if _print_user_info else None,
                 )
                 )
                 await db.commit()
                 await db.commit()
@@ -3616,10 +3673,40 @@ async def on_print_complete(printer_id: int, data: dict):
 
 
                 energy_cost_per_kwh = await get_setting(db, "energy_cost_per_kwh")
                 energy_cost_per_kwh = await get_setting(db, "energy_cost_per_kwh")
                 cost_per_kwh = float(energy_cost_per_kwh) if energy_cost_per_kwh else 0.15
                 cost_per_kwh = float(energy_cost_per_kwh) if energy_cost_per_kwh else 0.15
-                archive.energy_kwh = energy_used
-                archive.energy_cost = round(energy_used * cost_per_kwh, 3)
+                energy_cost_value = round(energy_used * cost_per_kwh, 3)
+
+                # First-run-only overwrite of archive.energy_kwh / energy_cost so a
+                # reprint doesn't visually clobber the source archive's energy data
+                # (#1378). Reprint energy lives in the matching PrintLogEntry below.
+                from sqlalchemy import func
+
+                from backend.app.models.print_log import PrintLogEntry
+
+                existing_runs = await db.scalar(
+                    select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == archive_id)
+                )
+                if (existing_runs or 0) <= 1:
+                    # 0 = legacy archive that pre-dates per-run logging; 1 = the row
+                    # we just wrote for THIS print. Either way it's the first run.
+                    archive.energy_kwh = energy_used
+                    archive.energy_cost = energy_cost_value
+
+                # Backfill the latest PrintLogEntry for this archive with energy
+                # (write_log_entry above ran before this background task completed,
+                # so energy fields are still NULL on that row).
+                latest_run = await db.execute(
+                    select(PrintLogEntry)
+                    .where(PrintLogEntry.archive_id == archive_id)
+                    .order_by(PrintLogEntry.id.desc())
+                    .limit(1)
+                )
+                run_row = latest_run.scalar_one_or_none()
+                if run_row is not None:
+                    run_row.energy_kwh = energy_used
+                    run_row.energy_cost = energy_cost_value
+
                 await db.commit()
                 await db.commit()
-                logger.info("[ENERGY-BG] Saved: %s kWh, cost=%s", energy_used, archive.energy_cost)
+                logger.info("[ENERGY-BG] Saved: %s kWh, cost=%s", energy_used, energy_cost_value)
         except Exception as e:
         except Exception as e:
             logger.warning("[ENERGY-BG] Failed: %s", e)
             logger.warning("[ENERGY-BG] Failed: %s", e)
 
 

+ 14 - 1
backend/app/models/print_log.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 from datetime import datetime
 
 
-from sqlalchemy import DateTime, Float, Integer, String, func
+from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, func
 from sqlalchemy.orm import Mapped, mapped_column
 from sqlalchemy.orm import Mapped, mapped_column
 
 
 from backend.app.core.database import Base
 from backend.app.core.database import Base
@@ -11,11 +11,19 @@ class PrintLogEntry(Base):
 
 
     This is a separate table from archives/queue — clearing the log
     This is a separate table from archives/queue — clearing the log
     never touches archives or queue items.
     never touches archives or queue items.
+
+    archive_id is a nullable FK so log entries survive archive deletion (ON
+    DELETE SET NULL). Aggregating runs per archive — for the per-archive
+    "Print Log" view and for statistics that should not double-count
+    overwritten archives (#1378) — is done via WHERE archive_id = X.
     """
     """
 
 
     __tablename__ = "print_log_entries"
     __tablename__ = "print_log_entries"
 
 
     id: Mapped[int] = mapped_column(primary_key=True)
     id: Mapped[int] = mapped_column(primary_key=True)
+    archive_id: Mapped[int | None] = mapped_column(
+        ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True, index=True
+    )
     print_name: Mapped[str | None] = mapped_column(String(255))
     print_name: Mapped[str | None] = mapped_column(String(255))
     printer_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)
     printer_id: Mapped[int | None] = mapped_column(Integer)
@@ -26,6 +34,11 @@ class PrintLogEntry(Base):
     filament_type: Mapped[str | None] = mapped_column(String(50))
     filament_type: Mapped[str | None] = mapped_column(String(50))
     filament_color: 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)
     filament_used_grams: Mapped[float | None] = mapped_column(Float)
+    cost: Mapped[float | None] = mapped_column(Float)
+    energy_kwh: Mapped[float | None] = mapped_column(Float)
+    energy_cost: Mapped[float | None] = mapped_column(Float)
+    failure_reason: Mapped[str | None] = mapped_column(String(100))
     thumbnail_path: Mapped[str | None] = mapped_column(String(500))
     thumbnail_path: Mapped[str | None] = mapped_column(String(500))
+    created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
     created_by_username: Mapped[str | None] = mapped_column(String(100))
     created_by_username: Mapped[str | None] = mapped_column(String(100))
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

+ 9 - 0
backend/app/schemas/archive.py

@@ -100,6 +100,15 @@ class ArchiveResponse(BaseModel):
     created_by_id: int | None = None
     created_by_id: int | None = None
     created_by_username: str | None = None
     created_by_username: str | None = None
 
 
+    # Per-archive run aggregates (#1378). Computed from PrintLogEntry — one
+    # row per actual print event — so reprints contribute to these counters
+    # without overwriting the source archive's first-run data.
+    run_count: int = 0
+    last_run_at: datetime | None = None
+    total_filament_actual_grams: float | None = None
+    successful_run_count: int = 0
+    failed_run_count: int = 0
+
     @model_validator(mode="after")
     @model_validator(mode="after")
     def compute_object_count(self) -> "ArchiveResponse":
     def compute_object_count(self) -> "ArchiveResponse":
         """Compute object_count from extra_data.printable_objects if not set."""
         """Compute object_count from extra_data.printable_objects if not set."""

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

@@ -5,6 +5,7 @@ from pydantic import BaseModel
 
 
 class PrintLogEntrySchema(BaseModel):
 class PrintLogEntrySchema(BaseModel):
     id: int
     id: int
+    archive_id: int | None = None
     print_name: str | None = None
     print_name: str | None = None
     printer_name: str | None = None
     printer_name: str | None = None
     printer_id: int | None = None
     printer_id: int | None = None
@@ -15,7 +16,12 @@ class PrintLogEntrySchema(BaseModel):
     filament_type: str | None = None
     filament_type: str | None = None
     filament_color: str | None = None
     filament_color: str | None = None
     filament_used_grams: float | None = None
     filament_used_grams: float | None = None
+    cost: float | None = None
+    energy_kwh: float | None = None
+    energy_cost: float | None = None
+    failure_reason: str | None = None
     thumbnail_path: str | None = None
     thumbnail_path: str | None = None
+    created_by_id: int | None = None
     created_by_username: str | None = None
     created_by_username: str | None = None
     created_at: datetime
     created_at: datetime
 
 

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

@@ -17,6 +17,7 @@ async def write_log_entry(
     db: AsyncSession,
     db: AsyncSession,
     *,
     *,
     status: str,
     status: str,
+    archive_id: int | None = None,
     print_name: str | None = None,
     print_name: str | None = None,
     printer_name: str | None = None,
     printer_name: str | None = None,
     printer_id: int | None = None,
     printer_id: int | None = None,
@@ -25,7 +26,12 @@ async def write_log_entry(
     filament_type: str | None = None,
     filament_type: str | None = None,
     filament_color: str | None = None,
     filament_color: str | None = None,
     filament_used_grams: float | None = None,
     filament_used_grams: float | None = None,
+    cost: float | None = None,
+    energy_kwh: float | None = None,
+    energy_cost: float | None = None,
+    failure_reason: str | None = None,
     thumbnail_path: str | None = None,
     thumbnail_path: str | None = None,
+    created_by_id: int | None = None,
     created_by_username: str | None = None,
     created_by_username: str | None = None,
 ) -> PrintLogEntry:
 ) -> PrintLogEntry:
     """Write a print log entry."""
     """Write a print log entry."""
@@ -34,6 +40,7 @@ async def write_log_entry(
         duration = int((completed_at - started_at).total_seconds())
         duration = int((completed_at - started_at).total_seconds())
 
 
     entry = PrintLogEntry(
     entry = PrintLogEntry(
+        archive_id=archive_id,
         print_name=print_name,
         print_name=print_name,
         printer_name=printer_name,
         printer_name=printer_name,
         printer_id=printer_id,
         printer_id=printer_id,
@@ -44,7 +51,12 @@ async def write_log_entry(
         filament_type=filament_type,
         filament_type=filament_type,
         filament_color=filament_color,
         filament_color=filament_color,
         filament_used_grams=filament_used_grams,
         filament_used_grams=filament_used_grams,
+        cost=cost,
+        energy_kwh=energy_kwh,
+        energy_cost=energy_cost,
+        failure_reason=failure_reason,
         thumbnail_path=thumbnail_path,
         thumbnail_path=thumbnail_path,
+        created_by_id=created_by_id,
         created_by_username=created_by_username,
         created_by_username=created_by_username,
     )
     )
     db.add(entry)
     db.add(entry)

+ 13 - 3
backend/app/services/usage_tracker.py

@@ -616,9 +616,10 @@ async def on_print_complete(
     # so the overwrite must reconstruct the whole-print cost.
     # so the overwrite must reconstruct the whole-print cost.
 
 
     if archive_id and results:
     if archive_id and results:
-        from sqlalchemy import select
+        from sqlalchemy import func, select
 
 
         from backend.app.models.archive import PrintArchive
         from backend.app.models.archive import PrintArchive
+        from backend.app.models.print_log import PrintLogEntry
 
 
         archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
         archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
         archive = archive_result.scalar_one_or_none()
         archive = archive_result.scalar_one_or_none()
@@ -630,8 +631,17 @@ async def on_print_complete(
             if untracked_grams > 0 and default_filament_cost > 0:
             if untracked_grams > 0 and default_filament_cost > 0:
                 total_cost += (untracked_grams / 1000.0) * default_filament_cost
                 total_cost += (untracked_grams / 1000.0) * default_filament_cost
             if total_cost > 0:
             if total_cost > 0:
-                archive.cost = round(total_cost, 2)
-                await db.commit()
+                # Only overwrite archive.cost on the first run. Reprint actuals
+                # live in PrintLogEntry; the archive card keeps the first run's
+                # cost so a failed reprint doesn't visually clobber a successful
+                # 100 g/$X print with a 10 g/$X/10 partial (#1378).
+                _existing_runs_result = await db.execute(
+                    select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == archive_id)
+                )
+                _existing_runs = _existing_runs_result.scalar()
+                if not _existing_runs:
+                    archive.cost = round(total_cost, 2)
+                    await db.commit()
 
 
     return results
     return results
 
 

+ 38 - 1
backend/tests/conftest.py

@@ -116,6 +116,7 @@ async def test_engine():
         notification,
         notification,
         notification_template,
         notification_template,
         oidc_provider,
         oidc_provider,
+        print_log,
         print_queue,
         print_queue,
         printer,
         printer,
         project,
         project,
@@ -504,10 +505,21 @@ def notification_provider_factory(db_session):
 
 
 @pytest.fixture
 @pytest.fixture
 def archive_factory(db_session):
 def archive_factory(db_session):
-    """Factory to create test archives."""
+    """Factory to create test archives.
+
+    Also synthesizes one PrintLogEntry per archive (matching the production
+    flow where statistics are aggregated from PrintLogEntry, not PrintArchive,
+    per #1378). Pass ``with_run=False`` to skip — useful for testing the
+    "archived but never printed" state. Pass ``run_status=...`` to override
+    the run's status independently of the archive's status field.
+    """
 
 
     async def _create_archive(printer_id: int, **kwargs):
     async def _create_archive(printer_id: int, **kwargs):
         from backend.app.models.archive import PrintArchive
         from backend.app.models.archive import PrintArchive
+        from backend.app.models.print_log import PrintLogEntry
+
+        with_run = kwargs.pop("with_run", True)
+        run_status = kwargs.pop("run_status", None)
 
 
         defaults = {
         defaults = {
             "printer_id": printer_id,
             "printer_id": printer_id,
@@ -526,6 +538,31 @@ def archive_factory(db_session):
         db_session.add(archive)
         db_session.add(archive)
         await db_session.commit()
         await db_session.commit()
         await db_session.refresh(archive)
         await db_session.refresh(archive)
+
+        if with_run:
+            duration = None
+            if archive.started_at and archive.completed_at:
+                duration = int((archive.completed_at - archive.started_at).total_seconds()) or None
+            run = PrintLogEntry(
+                archive_id=archive.id,
+                printer_id=archive.printer_id,
+                status=run_status or archive.status,
+                started_at=archive.started_at,
+                completed_at=archive.completed_at,
+                duration_seconds=duration,
+                filament_type=archive.filament_type,
+                filament_color=archive.filament_color,
+                filament_used_grams=archive.filament_used_grams,
+                cost=archive.cost,
+                energy_kwh=archive.energy_kwh,
+                energy_cost=archive.energy_cost,
+                failure_reason=archive.failure_reason,
+                print_name=archive.print_name,
+                created_by_id=archive.created_by_id,
+            )
+            db_session.add(run)
+            await db_session.commit()
+
         return archive
         return archive
 
 
     return _create_archive
     return _create_archive

+ 247 - 0
backend/tests/unit/test_archive_run_aggregation.py

@@ -0,0 +1,247 @@
+"""Tests for the PrintRun-based stats aggregation (#1378).
+
+Statistics and per-archive aggregates now come from PrintLogEntry rows rather
+than PrintArchive's runtime fields, so a reprint contributes new totals
+instead of overwriting the source archive's first-run data.
+"""
+
+from datetime import datetime, timezone
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.models.print_log import PrintLogEntry
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_stats_count_reprints_independently(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session
+):
+    """A reprint adds to stats instead of overwriting the source archive."""
+    printer = await printer_factory()
+    archive = await archive_factory(
+        printer.id,
+        status="completed",
+        filament_used_grams=100.0,
+        cost=2.5,
+        print_time_seconds=3600,
+        with_run=False,
+    )
+
+    # First run — completed, 100g.
+    db_session.add(
+        PrintLogEntry(
+            archive_id=archive.id,
+            printer_id=archive.printer_id,
+            status="completed",
+            started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
+            completed_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc),
+            duration_seconds=3600,
+            filament_used_grams=100.0,
+            cost=2.5,
+            created_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc),
+        )
+    )
+    # Reprint — failed at 10g.
+    db_session.add(
+        PrintLogEntry(
+            archive_id=archive.id,
+            printer_id=archive.printer_id,
+            status="failed",
+            started_at=datetime(2026, 5, 5, 10, 0, tzinfo=timezone.utc),
+            completed_at=datetime(2026, 5, 5, 10, 5, tzinfo=timezone.utc),
+            duration_seconds=300,
+            filament_used_grams=10.0,
+            cost=0.25,
+            failure_reason="Cancelled by user",
+            created_at=datetime(2026, 5, 5, 10, 5, tzinfo=timezone.utc),
+        )
+    )
+    await db_session.commit()
+
+    response = await async_client.get("/api/v1/archives/stats")
+    assert response.status_code == 200
+    body = response.json()
+
+    # Both runs counted, not the single archive row.
+    assert body["total_prints"] == 2
+    assert body["successful_prints"] == 1
+    assert body["failed_prints"] == 1
+
+    # 100g + 10g — NOT 10g (which is what archives.filament_used_grams alone
+    # would give if the archive's runtime fields were the source of truth).
+    assert body["total_filament_grams"] == pytest.approx(110.0)
+    assert body["total_cost"] == pytest.approx(2.75)
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_archive_list_includes_run_aggregates(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session
+):
+    """List response carries run_count, last_run_at, total_filament_actual_grams."""
+    printer = await printer_factory()
+    archive = await archive_factory(
+        printer.id,
+        status="completed",
+        filament_used_grams=100.0,
+        with_run=False,
+    )
+    db_session.add_all(
+        [
+            PrintLogEntry(
+                archive_id=archive.id,
+                printer_id=archive.printer_id,
+                status="completed",
+                started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
+                completed_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc),
+                filament_used_grams=100.0,
+                created_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc),
+            ),
+            PrintLogEntry(
+                archive_id=archive.id,
+                printer_id=archive.printer_id,
+                status="failed",
+                started_at=datetime(2026, 5, 10, 10, 0, tzinfo=timezone.utc),
+                completed_at=datetime(2026, 5, 10, 10, 5, tzinfo=timezone.utc),
+                filament_used_grams=10.0,
+                created_at=datetime(2026, 5, 10, 10, 5, tzinfo=timezone.utc),
+            ),
+        ]
+    )
+    await db_session.commit()
+
+    response = await async_client.get("/api/v1/archives/")
+    assert response.status_code == 200
+    rows = response.json()
+    row = next(r for r in rows if r["id"] == archive.id)
+
+    assert row["run_count"] == 2
+    assert row["successful_run_count"] == 1
+    assert row["failed_run_count"] == 1
+    assert row["total_filament_actual_grams"] == pytest.approx(110.0)
+    assert row["last_run_at"] is not None  # max(started_at) populated
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_runs_endpoint_returns_runs_newest_first(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session
+):
+    """GET /archives/{id}/runs returns each PrintLogEntry for the archive."""
+    printer = await printer_factory()
+    archive = await archive_factory(
+        printer.id,
+        status="completed",
+        with_run=False,
+    )
+    db_session.add_all(
+        [
+            PrintLogEntry(
+                archive_id=archive.id,
+                printer_id=archive.printer_id,
+                status="completed",
+                started_at=datetime(2026, 4, 1, 10, 0, tzinfo=timezone.utc),
+                completed_at=datetime(2026, 4, 1, 11, 0, tzinfo=timezone.utc),
+                filament_used_grams=50.0,
+            ),
+            PrintLogEntry(
+                archive_id=archive.id,
+                printer_id=archive.printer_id,
+                status="failed",
+                started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
+                completed_at=datetime(2026, 5, 1, 10, 5, tzinfo=timezone.utc),
+                filament_used_grams=5.0,
+                failure_reason="Cancelled by user",
+            ),
+        ]
+    )
+    await db_session.commit()
+
+    response = await async_client.get(f"/api/v1/archives/{archive.id}/runs")
+    assert response.status_code == 200
+    body = response.json()
+    assert body["total"] == 2
+    # Newest first
+    assert body["items"][0]["status"] == "failed"
+    assert body["items"][0]["failure_reason"] == "Cancelled by user"
+    assert body["items"][1]["status"] == "completed"
+    assert body["items"][1]["filament_used_grams"] == pytest.approx(50.0)
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_purge_stats_also_deletes_linked_runs(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session
+):
+    """``DELETE /archives/{id}?purge_stats=true`` hard-deletes linked PrintLogEntry
+    rows so their filament / cost / count contributions truly leave Quick Stats.
+    Without this, ON DELETE SET NULL on the FK would orphan the runs and they'd
+    keep showing up in the new aggregate-from-PrintLogEntry totals (#1378)."""
+    from sqlalchemy import func, select
+
+    printer = await printer_factory()
+    keep = await archive_factory(printer.id, status="completed", filament_used_grams=50.0)
+    purge = await archive_factory(printer.id, status="completed", filament_used_grams=100.0)
+
+    # Extra runs on the archive about to be purged, to prove they all go.
+    db_session.add_all(
+        [
+            PrintLogEntry(
+                archive_id=purge.id,
+                printer_id=purge.printer_id,
+                status="failed",
+                filament_used_grams=10.0,
+            ),
+            PrintLogEntry(
+                archive_id=purge.id,
+                printer_id=purge.printer_id,
+                status="completed",
+                filament_used_grams=100.0,
+            ),
+        ]
+    )
+    await db_session.commit()
+
+    resp = await async_client.delete(f"/api/v1/archives/{purge.id}?purge_stats=true")
+    assert resp.status_code == 200
+    assert resp.json()["purged_from_stats"] is True
+
+    remaining = await db_session.execute(
+        select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == purge.id)
+    )
+    assert remaining.scalar() == 0
+
+    # The OTHER archive's auto-synthesized run is still there.
+    keep_remaining = await db_session.execute(
+        select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == keep.id)
+    )
+    assert keep_remaining.scalar() == 1
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_soft_delete_keeps_runs_for_stats(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session
+):
+    """Default soft-delete (without ``purge_stats=true``) keeps the archive's
+    PrintLogEntry rows so the #1343 stats-preservation contract still holds —
+    the archive disappears from listings, but its filament / time / cost stay
+    in Quick Stats."""
+    from sqlalchemy import func, select
+
+    printer = await printer_factory()
+    archive = await archive_factory(printer.id, status="completed", filament_used_grams=75.0)
+
+    resp = await async_client.delete(f"/api/v1/archives/{archive.id}")
+    assert resp.status_code == 200
+    assert resp.json()["purged_from_stats"] is False
+
+    # The run row is still there for stats aggregation.
+    runs = await db_session.execute(select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == archive.id))
+    assert runs.scalar() == 1
+
+    stats = (await async_client.get("/api/v1/archives/stats")).json()
+    assert stats["total_prints"] >= 1
+    assert stats["total_filament_grams"] >= 75.0

+ 69 - 0
backend/tests/unit/test_run_filament_helper.py

@@ -0,0 +1,69 @@
+"""Unit tests for the per-run filament helper (#1378).
+
+The helper computes what value to write into PrintLogEntry.filament_used_grams
+for a given print event — partial-aware so failed / cancelled / stopped prints
+don't inflate stats with the full slicer estimate.
+"""
+
+from backend.app.main import _compute_run_filament_grams
+
+
+class TestComputeRunFilamentGrams:
+    def test_completed_returns_archive_estimate(self):
+        # Completed print: the slicer estimate is approximately what was used.
+        assert _compute_run_filament_grams("completed", 100.0, 100, []) == 100.0
+
+    def test_completed_returns_estimate_even_when_tracked_differs(self):
+        # When a print completes, the estimate is the canonical "this print used X"
+        # value — the tracked spool delta might be lower (some slots untracked)
+        # but the print is done, so the full estimate is the right answer.
+        assert _compute_run_filament_grams("completed", 100.0, 100, [{"weight_used": 10}]) == 100.0
+
+    def test_failed_uses_tracked_spool_delta(self):
+        # Failed reprint at 10g actual: inventory tracked the spool delta.
+        # The estimate was 100g; we want 10g recorded for stats.
+        assert _compute_run_filament_grams("failed", 100.0, 10, [{"weight_used": 10.0}]) == 10.0
+
+    def test_cancelled_uses_tracked_spool_delta(self):
+        # Same logic for cancelled.
+        assert _compute_run_filament_grams("cancelled", 100.0, 12, [{"weight_used": 8.5}]) == 8.5
+
+    def test_stopped_uses_tracked_spool_delta(self):
+        assert _compute_run_filament_grams("stopped", 100.0, 15, [{"weight_used": 12.0}]) == 12.0
+
+    def test_failed_with_no_tracked_falls_back_to_progress_scale(self):
+        # No inventory tracking: scale estimate by progress% (10% of 100g = 10g).
+        assert _compute_run_filament_grams("failed", 100.0, 10, []) == 10.0
+
+    def test_failed_with_no_tracked_and_no_progress_returns_none(self):
+        # Nothing to infer from — return None rather than guess the estimate.
+        assert _compute_run_filament_grams("failed", 100.0, 0, []) is None
+
+    def test_failed_with_partial_progress_rounds_correctly(self):
+        # 100g × 33% = 33.0g (rounded to 1 decimal)
+        assert _compute_run_filament_grams("failed", 100.0, 33, []) == 33.0
+
+    def test_failed_with_no_estimate_returns_none(self):
+        # No estimate, no tracked usage → can't compute anything.
+        assert _compute_run_filament_grams("failed", None, 50, []) is None
+
+    def test_failed_with_no_estimate_but_tracked_uses_tracked(self):
+        # Tracked spool delta is authoritative even without an estimate.
+        assert _compute_run_filament_grams("failed", None, 50, [{"weight_used": 5.0}]) == 5.0
+
+    def test_tracked_overrides_progress_scale_when_both_available(self):
+        # If inventory says 8g but progress says 15g, trust inventory (it's measured).
+        assert _compute_run_filament_grams("failed", 100.0, 15, [{"weight_used": 8.0}]) == 8.0
+
+    def test_progress_above_100_clamps_to_full_estimate(self):
+        # Defensive: progress overshoot doesn't multiply past the estimate.
+        assert _compute_run_filament_grams("failed", 100.0, 150, []) == 100.0
+
+    def test_multiple_tracked_slots_summed(self):
+        # Multi-filament print, two slots tracked.
+        usage = [{"weight_used": 5.0}, {"weight_used": 3.5}, {"weight_used": 1.0}]
+        assert _compute_run_filament_grams("failed", 100.0, 20, usage) == 9.5
+
+    def test_completed_with_none_estimate_returns_none(self):
+        # Archive somehow has no estimate (rare; archive_print parsed nothing).
+        assert _compute_run_filament_grams("completed", None, 100, []) is None

+ 136 - 0
frontend/src/__tests__/components/PrintLogModal.test.tsx

@@ -0,0 +1,136 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { render } from '../utils';
+import { PrintLogModal } from '../../components/PrintLogModal';
+import { api } from '../../api/client';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getArchiveRuns: vi.fn(),
+    getSettings: vi.fn().mockResolvedValue({}),
+    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
+  },
+}));
+
+const sampleRuns = {
+  total: 2,
+  items: [
+    {
+      id: 99,
+      archive_id: 42,
+      print_name: 'Benchy',
+      printer_name: 'X1C-01',
+      printer_id: 3,
+      status: 'failed',
+      started_at: '2026-05-10T10:00:00Z',
+      completed_at: '2026-05-10T10:05:00Z',
+      duration_seconds: 300,
+      filament_type: 'PLA',
+      filament_color: '#FF0000',
+      filament_used_grams: 10.0,
+      cost: 0.25,
+      energy_kwh: 0.05,
+      energy_cost: 0.01,
+      failure_reason: 'Cancelled by user',
+      thumbnail_path: null,
+      created_by_id: 1,
+      created_by_username: 'admin',
+      created_at: '2026-05-10T10:05:00Z',
+    },
+    {
+      id: 50,
+      archive_id: 42,
+      print_name: 'Benchy',
+      printer_name: 'X1C-01',
+      printer_id: 3,
+      status: 'completed',
+      started_at: '2026-05-01T10:00:00Z',
+      completed_at: '2026-05-01T11:00:00Z',
+      duration_seconds: 3600,
+      filament_type: 'PLA',
+      filament_color: '#FF0000',
+      filament_used_grams: 100.0,
+      cost: 2.5,
+      energy_kwh: 0.2,
+      energy_cost: 0.05,
+      failure_reason: null,
+      thumbnail_path: null,
+      created_by_id: 1,
+      created_by_username: 'admin',
+      created_at: '2026-05-01T11:00:00Z',
+    },
+  ],
+};
+
+describe('PrintLogModal', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.mocked(api.getArchiveRuns).mockResolvedValue(sampleRuns);
+  });
+
+  it('renders the archive name in the modal title', async () => {
+    render(<PrintLogModal archiveId={42} archiveName="Benchy" onClose={vi.fn()} />);
+    await waitFor(() => {
+      expect(screen.getByText(/Benchy/)).toBeInTheDocument();
+    });
+  });
+
+  it('falls back to "this archive" when name is null', async () => {
+    render(<PrintLogModal archiveId={42} archiveName={null} onClose={vi.fn()} />);
+    await waitFor(() => {
+      expect(screen.getByText(/this archive/i)).toBeInTheDocument();
+    });
+  });
+
+  it('lists every run with its filament + cost', async () => {
+    render(<PrintLogModal archiveId={42} archiveName="Benchy" onClose={vi.fn()} />);
+    await waitFor(() => {
+      expect(screen.getByText(/100\.0 g/)).toBeInTheDocument();
+      expect(screen.getByText(/10\.0 g/)).toBeInTheDocument();
+    });
+  });
+
+  it('shows failure_reason under failed runs', async () => {
+    render(<PrintLogModal archiveId={42} archiveName="Benchy" onClose={vi.fn()} />);
+    await waitFor(() => {
+      expect(screen.getByText('Cancelled by user')).toBeInTheDocument();
+    });
+  });
+
+  it('shows the empty state when there are no runs', async () => {
+    vi.mocked(api.getArchiveRuns).mockResolvedValue({ total: 0, items: [] });
+    render(<PrintLogModal archiveId={42} archiveName="Benchy" onClose={vi.fn()} />);
+    await waitFor(() => {
+      expect(screen.getByText(/no print events/i)).toBeInTheDocument();
+    });
+  });
+
+  it('calls onClose when Escape is pressed', async () => {
+    const onClose = vi.fn();
+    render(<PrintLogModal archiveId={42} archiveName="Benchy" onClose={onClose} />);
+    fireEvent.keyDown(window, { key: 'Escape' });
+    expect(onClose).toHaveBeenCalledTimes(1);
+  });
+
+  it('calls onClose when backdrop is clicked', async () => {
+    const onClose = vi.fn();
+    const { container } = render(
+      <PrintLogModal archiveId={42} archiveName="Benchy" onClose={onClose} />
+    );
+    const backdrop = container.querySelector('.fixed.inset-0');
+    if (!backdrop) throw new Error('Backdrop not found');
+    fireEvent.click(backdrop);
+    expect(onClose).toHaveBeenCalledTimes(1);
+  });
+
+  it('does not close when clicking inside the modal body', async () => {
+    const onClose = vi.fn();
+    render(<PrintLogModal archiveId={42} archiveName="Benchy" onClose={onClose} />);
+    await waitFor(() => {
+      expect(screen.getByText(/Benchy/)).toBeInTheDocument();
+    });
+    // Click on the title text inside the modal
+    fireEvent.click(screen.getByText(/Benchy/));
+    expect(onClose).not.toHaveBeenCalled();
+  });
+});

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

@@ -487,6 +487,12 @@ export interface Archive {
   // User tracking (Issue #206)
   // User tracking (Issue #206)
   created_by_id: number | null;
   created_by_id: number | null;
   created_by_username: string | null;
   created_by_username: string | null;
+  // Per-archive run aggregates from PrintLogEntry (#1378)
+  run_count: number;
+  last_run_at: string | null;
+  total_filament_actual_grams: number | null;
+  successful_run_count: number;
+  failed_run_count: number;
 }
 }
 
 
 export interface ArchiveSlim {
 export interface ArchiveSlim {
@@ -507,6 +513,7 @@ export interface ArchiveSlim {
 
 
 export interface PrintLogEntry {
 export interface PrintLogEntry {
   id: number;
   id: number;
+  archive_id: number | null;
   print_name: string | null;
   print_name: string | null;
   printer_name: string | null;
   printer_name: string | null;
   printer_id: number | null;
   printer_id: number | null;
@@ -517,7 +524,12 @@ export interface PrintLogEntry {
   filament_type: string | null;
   filament_type: string | null;
   filament_color: string | null;
   filament_color: string | null;
   filament_used_grams: number | null;
   filament_used_grams: number | null;
+  cost: number | null;
+  energy_kwh: number | null;
+  energy_cost: number | null;
+  failure_reason: string | null;
   thumbnail_path: string | null;
   thumbnail_path: string | null;
+  created_by_id: number | null;
   created_by_username: string | null;
   created_by_username: string | null;
   created_at: string;
   created_at: string;
 }
 }
@@ -3445,6 +3457,7 @@ export const api = {
     return request<ArchiveSlim[]>(`/archives/slim${qs ? `?${qs}` : ''}`);
     return request<ArchiveSlim[]>(`/archives/slim${qs ? `?${qs}` : ''}`);
   },
   },
   getArchive: (id: number) => request<Archive>(`/archives/${id}`),
   getArchive: (id: number) => request<Archive>(`/archives/${id}`),
+  getArchiveRuns: (id: number) => request<PrintLogResponse>(`/archives/${id}/runs`),
   searchArchives: (query: string, options?: {
   searchArchives: (query: string, options?: {
     printerId?: number;
     printerId?: number;
     projectId?: number;
     projectId?: number;

+ 8 - 0
frontend/src/components/EditArchiveModal.tsx

@@ -5,6 +5,7 @@ import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban, Hash, Link }
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { Archive } from '../api/client';
 import type { Archive } from '../api/client';
 import { Button } from './Button';
 import { Button } from './Button';
+import { PrintLogTable } from './PrintLogTable';
 
 
 // Keys for failure reasons - translated at render time
 // Keys for failure reasons - translated at render time
 const FAILURE_REASON_KEYS = [
 const FAILURE_REASON_KEYS = [
@@ -217,6 +218,13 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
 
 
         {/* Form */}
         {/* Form */}
         <form onSubmit={handleSubmit} className="p-6 space-y-4 overflow-y-auto flex-1">
         <form onSubmit={handleSubmit} className="p-6 space-y-4 overflow-y-auto flex-1">
+          {/* Print Log — per-run history pulled from PrintLogEntry (#1378). Shown
+              first so users can see which runs contributed to the aggregate stats. */}
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1">{t('archives.runLog.title')}</label>
+            <PrintLogTable archiveId={archive.id} />
+          </div>
+
           {/* Print Name */}
           {/* Print Name */}
           <div>
           <div>
             <label className="block text-sm text-bambu-gray mb-1">{t('editArchive.name')}</label>
             <label className="block text-sm text-bambu-gray mb-1">{t('editArchive.name')}</label>

+ 53 - 0
frontend/src/components/PrintLogModal.tsx

@@ -0,0 +1,53 @@
+import { useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import { X, History } from 'lucide-react';
+import { PrintLogTable } from './PrintLogTable';
+
+interface PrintLogModalProps {
+  archiveId: number;
+  archiveName: string | null;
+  onClose: () => void;
+}
+
+export function PrintLogModal({ archiveId, archiveName, onClose }: PrintLogModalProps) {
+  const { t } = useTranslation();
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
+      onClick={onClose}
+    >
+      <div
+        className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-2xl max-h-[85vh] flex flex-col"
+        onClick={(e) => e.stopPropagation()}
+      >
+        <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
+          <div className="flex items-center gap-2 min-w-0">
+            <History className="w-5 h-5 text-bambu-green flex-shrink-0" />
+            <h2 className="text-lg font-semibold text-white truncate" title={archiveName || ''}>
+              {t('archives.runLog.modalTitle', { name: archiveName || t('archives.runLog.modalTitleFallback') })}
+            </h2>
+          </div>
+          <button
+            onClick={onClose}
+            className="text-bambu-gray hover:text-white transition-colors"
+            title={t('common.close')}
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+        <div className="p-6 overflow-y-auto flex-1">
+          <PrintLogTable archiveId={archiveId} />
+        </div>
+      </div>
+    </div>
+  );
+}

+ 102 - 0
frontend/src/components/PrintLogTable.tsx

@@ -0,0 +1,102 @@
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Loader2 } from 'lucide-react';
+import { api } from '../api/client';
+
+interface PrintLogTableProps {
+  archiveId: number;
+}
+
+function formatDuration(seconds: number | null): string {
+  if (!seconds || seconds <= 0) return '—';
+  const h = Math.floor(seconds / 3600);
+  const m = Math.floor((seconds % 3600) / 60);
+  if (h > 0) return `${h}h ${m}m`;
+  return `${m}m`;
+}
+
+function formatDate(isoString: string | null): string {
+  if (!isoString) return '—';
+  const d = new Date(isoString);
+  return d.toLocaleString();
+}
+
+export function PrintLogTable({ archiveId }: PrintLogTableProps) {
+  const { t } = useTranslation();
+  const { data, isLoading } = useQuery({
+    queryKey: ['archive-runs', archiveId],
+    queryFn: () => api.getArchiveRuns(archiveId),
+  });
+
+  if (isLoading) {
+    return (
+      <div className="flex justify-center py-4">
+        <Loader2 className="w-5 h-5 text-bambu-gray animate-spin" />
+      </div>
+    );
+  }
+
+  const runs = data?.items || [];
+  if (runs.length === 0) {
+    return (
+      <p className="text-sm text-bambu-gray italic py-2">
+        {t('archives.runLog.empty')}
+      </p>
+    );
+  }
+
+  return (
+    <div className="overflow-x-auto">
+      <table className="w-full text-xs">
+        <thead>
+          <tr className="text-bambu-gray border-b border-bambu-dark-tertiary">
+            <th className="text-left py-1.5 pr-2 font-medium">{t('archives.runLog.col.date')}</th>
+            <th className="text-left py-1.5 pr-2 font-medium">{t('archives.runLog.col.status')}</th>
+            <th className="text-right py-1.5 pr-2 font-medium">{t('archives.runLog.col.duration')}</th>
+            <th className="text-right py-1.5 pr-2 font-medium">{t('archives.runLog.col.filament')}</th>
+            <th className="text-right py-1.5 font-medium">{t('archives.runLog.col.cost')}</th>
+          </tr>
+        </thead>
+        <tbody>
+          {runs.map((run) => {
+            const statusClass =
+              run.status === 'completed'
+                ? 'text-bambu-green'
+                : run.status === 'failed'
+                  ? 'text-red-400'
+                  : 'text-bambu-gray';
+            return (
+              <tr
+                key={run.id}
+                className="border-b border-bambu-dark-tertiary/40 last:border-0"
+              >
+                <td className="py-1.5 pr-2 text-bambu-gray-light">
+                  {formatDate(run.started_at || run.created_at)}
+                </td>
+                <td className={`py-1.5 pr-2 font-medium ${statusClass}`}>
+                  {t(`archives.runLog.status.${run.status}`, { defaultValue: run.status })}
+                  {run.failure_reason && (
+                    <span className="block text-[10px] text-bambu-gray font-normal">
+                      {run.failure_reason}
+                    </span>
+                  )}
+                </td>
+                <td className="py-1.5 pr-2 text-right text-bambu-gray-light">
+                  {formatDuration(run.duration_seconds)}
+                </td>
+                <td className="py-1.5 pr-2 text-right text-bambu-gray-light">
+                  {run.filament_used_grams != null
+                    ? `${run.filament_used_grams.toFixed(1)} g`
+                    : '—'}
+                </td>
+                <td className="py-1.5 text-right text-bambu-gray-light">
+                  {run.cost != null ? run.cost.toFixed(2) : '—'}
+                </td>
+              </tr>
+            );
+          })}
+        </tbody>
+      </table>
+    </div>
+  );
+}

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

@@ -736,6 +736,7 @@ export default {
       addToFavorites: 'Zu Favoriten hinzufügen',
       addToFavorites: 'Zu Favoriten hinzufügen',
       removeFromFavorites: 'Aus Favoriten entfernen',
       removeFromFavorites: 'Aus Favoriten entfernen',
       edit: 'Bearbeiten',
       edit: 'Bearbeiten',
+      printLog: 'Druckprotokoll',
       goToProject: 'Zum Projekt: {{name}}',
       goToProject: 'Zum Projekt: {{name}}',
       addToProject: 'Zu Projekt hinzufügen',
       addToProject: 'Zu Projekt hinzufügen',
       removeFromProject: 'Aus Projekt entfernen',
       removeFromProject: 'Aus Projekt entfernen',
@@ -786,6 +787,8 @@ export default {
       gcode: 'GCODE',
       gcode: 'GCODE',
       source: 'QUELLE',
       source: 'QUELLE',
       project: 'Projekt: {{name}}',
       project: 'Projekt: {{name}}',
+      runsBadge: '{{count}} Drucke',
+      runsBadgeTitle: 'Insgesamt {{count}} Drucke — {{successful}} erfolgreich, {{failed}} fehlgeschlagen. Klicken, um das vollständige Druckprotokoll zu öffnen.',
       estimated: 'Geschätzt: {{time}}',
       estimated: 'Geschätzt: {{time}}',
       actual: 'Tatsächlich: {{time}}',
       actual: 'Tatsächlich: {{time}}',
       accuracy: 'Genauigkeit: {{percent}}%',
       accuracy: 'Genauigkeit: {{percent}}%',
@@ -815,6 +818,27 @@ export default {
       edit: 'Bearbeiten',
       edit: 'Bearbeiten',
       delete: 'Löschen',
       delete: 'Löschen',
     },
     },
+    runLog: {
+      title: 'Druckprotokoll',
+      modalTitle: 'Druckprotokoll — {{name}}',
+      modalTitleFallback: 'dieses Archiv',
+      empty: 'Für dieses Archiv wurden noch keine Druckereignisse aufgezeichnet.',
+      col: {
+        date: 'Datum',
+        status: 'Status',
+        duration: 'Dauer',
+        filament: 'Filament',
+        cost: 'Kosten',
+      },
+      status: {
+        completed: 'Abgeschlossen',
+        failed: 'Fehlgeschlagen',
+        cancelled: 'Abgebrochen',
+        stopped: 'Gestoppt',
+        skipped: 'Übersprungen',
+        printing: 'Druckt',
+      },
+    },
     modal: {
     modal: {
       deleteArchive: 'Archiv löschen',
       deleteArchive: 'Archiv löschen',
       deleteConfirm: 'Möchten Sie "{{name}}" wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',
       deleteConfirm: 'Möchten Sie "{{name}}" wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',

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

@@ -736,6 +736,7 @@ export default {
       addToFavorites: 'Add to Favorites',
       addToFavorites: 'Add to Favorites',
       removeFromFavorites: 'Remove from Favorites',
       removeFromFavorites: 'Remove from Favorites',
       edit: 'Edit',
       edit: 'Edit',
+      printLog: 'Print Log',
       goToProject: 'Go to Project: {{name}}',
       goToProject: 'Go to Project: {{name}}',
       addToProject: 'Add to Project',
       addToProject: 'Add to Project',
       removeFromProject: 'Remove from Project',
       removeFromProject: 'Remove from Project',
@@ -786,6 +787,8 @@ export default {
       gcode: 'GCODE',
       gcode: 'GCODE',
       source: 'SOURCE',
       source: 'SOURCE',
       project: 'Project: {{name}}',
       project: 'Project: {{name}}',
+      runsBadge: '{{count}} prints',
+      runsBadgeTitle: '{{count}} prints total — {{successful}} successful, {{failed}} failed. Click to see the full print log.',
       estimated: 'Estimated: {{time}}',
       estimated: 'Estimated: {{time}}',
       actual: 'Actual: {{time}}',
       actual: 'Actual: {{time}}',
       accuracy: 'Accuracy: {{percent}}%',
       accuracy: 'Accuracy: {{percent}}%',
@@ -815,6 +818,27 @@ export default {
       edit: 'Edit',
       edit: 'Edit',
       delete: 'Delete',
       delete: 'Delete',
     },
     },
+    runLog: {
+      title: 'Print Log',
+      modalTitle: 'Print Log — {{name}}',
+      modalTitleFallback: 'this archive',
+      empty: 'No print events recorded for this archive yet.',
+      col: {
+        date: 'Date',
+        status: 'Status',
+        duration: 'Duration',
+        filament: 'Filament',
+        cost: 'Cost',
+      },
+      status: {
+        completed: 'Completed',
+        failed: 'Failed',
+        cancelled: 'Cancelled',
+        stopped: 'Stopped',
+        skipped: 'Skipped',
+        printing: 'Printing',
+      },
+    },
     modal: {
     modal: {
       deleteArchive: 'Delete Archive',
       deleteArchive: 'Delete Archive',
       deleteConfirm: 'Are you sure you want to delete "{{name}}"? This action cannot be undone.',
       deleteConfirm: 'Are you sure you want to delete "{{name}}"? This action cannot be undone.',

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

@@ -736,6 +736,7 @@ export default {
       addToFavorites: 'Ajouter aux favoris',
       addToFavorites: 'Ajouter aux favoris',
       removeFromFavorites: 'Retirer des favoris',
       removeFromFavorites: 'Retirer des favoris',
       edit: 'Modifier',
       edit: 'Modifier',
+      printLog: 'Journal d\'impression',
       goToProject: 'Aller au Projet : {{name}}',
       goToProject: 'Aller au Projet : {{name}}',
       addToProject: 'Ajouter au Projet',
       addToProject: 'Ajouter au Projet',
       removeFromProject: 'Retirer du Projet',
       removeFromProject: 'Retirer du Projet',
@@ -786,6 +787,8 @@ export default {
       gcode: 'GCODE',
       gcode: 'GCODE',
       source: 'SOURCE',
       source: 'SOURCE',
       project: 'Projet : {{name}}',
       project: 'Projet : {{name}}',
+      runsBadge: '{{count}} impressions',
+      runsBadgeTitle: '{{count}} impressions au total — {{successful}} réussies, {{failed}} échouées. Cliquez pour voir le journal complet.',
       estimated: 'Estimé : {{time}}',
       estimated: 'Estimé : {{time}}',
       actual: 'Réel : {{time}}',
       actual: 'Réel : {{time}}',
       accuracy: 'Précision : {{percent}}%',
       accuracy: 'Précision : {{percent}}%',
@@ -815,6 +818,27 @@ export default {
       edit: 'Modifier',
       edit: 'Modifier',
       delete: 'Supprimer',
       delete: 'Supprimer',
     },
     },
+    runLog: {
+      title: 'Journal d\'impression',
+      modalTitle: 'Journal d\'impression — {{name}}',
+      modalTitleFallback: 'cette archive',
+      empty: 'Aucun événement d\'impression enregistré pour cette archive.',
+      col: {
+        date: 'Date',
+        status: 'Statut',
+        duration: 'Durée',
+        filament: 'Filament',
+        cost: 'Coût',
+      },
+      status: {
+        completed: 'Terminé',
+        failed: 'Échoué',
+        cancelled: 'Annulé',
+        stopped: 'Arrêté',
+        skipped: 'Ignoré',
+        printing: 'Impression en cours',
+      },
+    },
     modal: {
     modal: {
       deleteArchive: 'Supprimer l\'archive',
       deleteArchive: 'Supprimer l\'archive',
       deleteConfirm: 'Supprimer "{{name}}" ? Cette action est irréversible.',
       deleteConfirm: 'Supprimer "{{name}}" ? Cette action est irréversible.',

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

@@ -736,6 +736,7 @@ export default {
       addToFavorites: 'Aggiungi ai preferiti',
       addToFavorites: 'Aggiungi ai preferiti',
       removeFromFavorites: 'Rimuovi dai preferiti',
       removeFromFavorites: 'Rimuovi dai preferiti',
       edit: 'Modifica',
       edit: 'Modifica',
+      printLog: 'Registro stampe',
       goToProject: 'Vai al progetto: {{name}}',
       goToProject: 'Vai al progetto: {{name}}',
       addToProject: 'Aggiungi al progetto',
       addToProject: 'Aggiungi al progetto',
       removeFromProject: 'Rimuovi dal progetto',
       removeFromProject: 'Rimuovi dal progetto',
@@ -786,6 +787,8 @@ export default {
       gcode: 'GCODE',
       gcode: 'GCODE',
       source: 'SOURCE',
       source: 'SOURCE',
       project: 'Progetto: {{name}}',
       project: 'Progetto: {{name}}',
+      runsBadge: '{{count}} stampe',
+      runsBadgeTitle: '{{count}} stampe in totale — {{successful}} riuscite, {{failed}} fallite. Fai clic per vedere il registro completo.',
       estimated: 'Stimato: {{time}}',
       estimated: 'Stimato: {{time}}',
       actual: 'Reale: {{time}}',
       actual: 'Reale: {{time}}',
       accuracy: 'Accuratezza: {{percent}}%',
       accuracy: 'Accuratezza: {{percent}}%',
@@ -815,6 +818,27 @@ export default {
       edit: 'Modifica',
       edit: 'Modifica',
       delete: 'Elimina',
       delete: 'Elimina',
     },
     },
+    runLog: {
+      title: 'Registro stampe',
+      modalTitle: 'Registro stampe — {{name}}',
+      modalTitleFallback: 'questo archivio',
+      empty: 'Nessun evento di stampa ancora registrato per questo archivio.',
+      col: {
+        date: 'Data',
+        status: 'Stato',
+        duration: 'Durata',
+        filament: 'Filamento',
+        cost: 'Costo',
+      },
+      status: {
+        completed: 'Completata',
+        failed: 'Fallita',
+        cancelled: 'Annullata',
+        stopped: 'Interrotta',
+        skipped: 'Saltata',
+        printing: 'In stampa',
+      },
+    },
     modal: {
     modal: {
       deleteArchive: 'Elimina Archivio',
       deleteArchive: 'Elimina Archivio',
       deleteConfirm: 'Sei sicuro di eliminare "{{name}}"? Questa azione non può essere annullata.',
       deleteConfirm: 'Sei sicuro di eliminare "{{name}}"? Questa azione non può essere annullata.',

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

@@ -735,6 +735,7 @@ export default {
       addToFavorites: 'お気に入りに追加',
       addToFavorites: 'お気に入りに追加',
       removeFromFavorites: 'お気に入りから削除',
       removeFromFavorites: 'お気に入りから削除',
       edit: '編集',
       edit: '編集',
+      printLog: '印刷ログ',
       goToProject: 'プロジェクトへ: {{name}}',
       goToProject: 'プロジェクトへ: {{name}}',
       addToProject: 'プロジェクトに追加',
       addToProject: 'プロジェクトに追加',
       removeFromProject: 'プロジェクトから削除',
       removeFromProject: 'プロジェクトから削除',
@@ -785,6 +786,8 @@ export default {
       gcode: 'GCODE',
       gcode: 'GCODE',
       source: 'ソース',
       source: 'ソース',
       project: 'プロジェクト: {{name}}',
       project: 'プロジェクト: {{name}}',
+      runsBadge: '{{count}} 回印刷',
+      runsBadgeTitle: '合計 {{count}} 回 — 成功 {{successful}} 回、失敗 {{failed}} 回。クリックして印刷ログを開きます。',
       estimated: '推定: {{time}}',
       estimated: '推定: {{time}}',
       actual: '実際: {{time}}',
       actual: '実際: {{time}}',
       accuracy: '精度: {{percent}}%',
       accuracy: '精度: {{percent}}%',
@@ -814,6 +817,27 @@ export default {
       edit: '編集',
       edit: '編集',
       delete: '削除',
       delete: '削除',
     },
     },
+    runLog: {
+      title: '印刷ログ',
+      modalTitle: '印刷ログ — {{name}}',
+      modalTitleFallback: 'このアーカイブ',
+      empty: 'このアーカイブの印刷イベントはまだ記録されていません。',
+      col: {
+        date: '日付',
+        status: 'ステータス',
+        duration: '時間',
+        filament: 'フィラメント',
+        cost: 'コスト',
+      },
+      status: {
+        completed: '完了',
+        failed: '失敗',
+        cancelled: 'キャンセル',
+        stopped: '停止',
+        skipped: 'スキップ',
+        printing: '印刷中',
+      },
+    },
     modal: {
     modal: {
       deleteArchive: 'アーカイブを削除',
       deleteArchive: 'アーカイブを削除',
       deleteConfirm: '"{{name}}" を削除しますか?この操作は取り消せません。',
       deleteConfirm: '"{{name}}" を削除しますか?この操作は取り消せません。',

+ 24 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -736,6 +736,7 @@ export default {
       addToFavorites: 'Adicionar aos favoritos',
       addToFavorites: 'Adicionar aos favoritos',
       removeFromFavorites: 'Remover dos favoritos',
       removeFromFavorites: 'Remover dos favoritos',
       edit: 'Editar',
       edit: 'Editar',
+      printLog: 'Histórico de impressões',
       goToProject: 'Ir para o projeto: {{name}}',
       goToProject: 'Ir para o projeto: {{name}}',
       addToProject: 'Adicionar ao projeto',
       addToProject: 'Adicionar ao projeto',
       removeFromProject: 'Remover do projeto',
       removeFromProject: 'Remover do projeto',
@@ -786,6 +787,8 @@ export default {
       gcode: 'GCODE',
       gcode: 'GCODE',
       source: 'SOURCE',
       source: 'SOURCE',
       project: 'Projeto: {{name}}',
       project: 'Projeto: {{name}}',
+      runsBadge: '{{count}} impressões',
+      runsBadgeTitle: '{{count}} impressões no total — {{successful}} bem-sucedidas, {{failed}} com falha. Clique para ver o histórico completo.',
       estimated: 'Estimado: {{time}}',
       estimated: 'Estimado: {{time}}',
       actual: 'Real: {{time}}',
       actual: 'Real: {{time}}',
       accuracy: 'Precisão: {{percent}}%',
       accuracy: 'Precisão: {{percent}}%',
@@ -815,6 +818,27 @@ export default {
       edit: 'Editar',
       edit: 'Editar',
       delete: 'Excluir',
       delete: 'Excluir',
     },
     },
+    runLog: {
+      title: 'Histórico de impressões',
+      modalTitle: 'Histórico de impressões — {{name}}',
+      modalTitleFallback: 'este arquivo',
+      empty: 'Nenhum evento de impressão registrado para este arquivo ainda.',
+      col: {
+        date: 'Data',
+        status: 'Status',
+        duration: 'Duração',
+        filament: 'Filamento',
+        cost: 'Custo',
+      },
+      status: {
+        completed: 'Concluída',
+        failed: 'Falhou',
+        cancelled: 'Cancelada',
+        stopped: 'Interrompida',
+        skipped: 'Ignorada',
+        printing: 'Imprimindo',
+      },
+    },
     modal: {
     modal: {
       deleteArchive: 'Excluir Arquivo',
       deleteArchive: 'Excluir Arquivo',
       deleteConfirm: 'Tem certeza de que deseja excluir "{{name}}"? Esta ação não pode ser desfeita.',
       deleteConfirm: 'Tem certeza de que deseja excluir "{{name}}"? Esta ação não pode ser desfeita.',

+ 24 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -736,6 +736,7 @@ export default {
       addToFavorites: '添加到收藏',
       addToFavorites: '添加到收藏',
       removeFromFavorites: '从收藏中移除',
       removeFromFavorites: '从收藏中移除',
       edit: '编辑',
       edit: '编辑',
+      printLog: '打印记录',
       goToProject: '前往项目:{{name}}',
       goToProject: '前往项目:{{name}}',
       addToProject: '添加到项目',
       addToProject: '添加到项目',
       removeFromProject: '从项目中移除',
       removeFromProject: '从项目中移除',
@@ -786,6 +787,8 @@ export default {
       gcode: 'GCODE',
       gcode: 'GCODE',
       source: '源文件',
       source: '源文件',
       project: '项目:{{name}}',
       project: '项目:{{name}}',
+      runsBadge: '{{count}} 次打印',
+      runsBadgeTitle: '共 {{count}} 次打印 — 成功 {{successful}} 次,失败 {{failed}} 次。点击查看完整打印记录。',
       estimated: '预计:{{time}}',
       estimated: '预计:{{time}}',
       actual: '实际:{{time}}',
       actual: '实际:{{time}}',
       accuracy: '准确度:{{percent}}%',
       accuracy: '准确度:{{percent}}%',
@@ -815,6 +818,27 @@ export default {
       edit: '编辑',
       edit: '编辑',
       delete: '删除',
       delete: '删除',
     },
     },
+    runLog: {
+      title: '打印记录',
+      modalTitle: '打印记录 — {{name}}',
+      modalTitleFallback: '此归档',
+      empty: '此归档尚未记录任何打印事件。',
+      col: {
+        date: '日期',
+        status: '状态',
+        duration: '时长',
+        filament: '耗材',
+        cost: '成本',
+      },
+      status: {
+        completed: '已完成',
+        failed: '失败',
+        cancelled: '已取消',
+        stopped: '已停止',
+        skipped: '已跳过',
+        printing: '打印中',
+      },
+    },
     modal: {
     modal: {
       deleteArchive: '删除归档',
       deleteArchive: '删除归档',
       deleteConfirm: '确定要删除"{{name}}"吗?此操作无法撤销。',
       deleteConfirm: '确定要删除"{{name}}"吗?此操作无法撤销。',

+ 24 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -736,6 +736,7 @@ export default {
       addToFavorites: '新增到收藏',
       addToFavorites: '新增到收藏',
       removeFromFavorites: '從收藏中移除',
       removeFromFavorites: '從收藏中移除',
       edit: '編輯',
       edit: '編輯',
+      printLog: '列印記錄',
       goToProject: '前往專案:{{name}}',
       goToProject: '前往專案:{{name}}',
       addToProject: '新增到專案',
       addToProject: '新增到專案',
       removeFromProject: '從專案中移除',
       removeFromProject: '從專案中移除',
@@ -786,6 +787,8 @@ export default {
       gcode: 'GCODE',
       gcode: 'GCODE',
       source: '原始檔',
       source: '原始檔',
       project: '專案:{{name}}',
       project: '專案:{{name}}',
+      runsBadge: '{{count}} 次列印',
+      runsBadgeTitle: '共 {{count}} 次列印 — 成功 {{successful}} 次,失敗 {{failed}} 次。點擊查看完整列印記錄。',
       estimated: '預計:{{time}}',
       estimated: '預計:{{time}}',
       actual: '實際:{{time}}',
       actual: '實際:{{time}}',
       accuracy: '準確度:{{percent}}%',
       accuracy: '準確度:{{percent}}%',
@@ -815,6 +818,27 @@ export default {
       edit: '編輯',
       edit: '編輯',
       delete: '刪除',
       delete: '刪除',
     },
     },
+    runLog: {
+      title: '列印記錄',
+      modalTitle: '列印記錄 — {{name}}',
+      modalTitleFallback: '此歸檔',
+      empty: '此歸檔尚未記錄任何列印事件。',
+      col: {
+        date: '日期',
+        status: '狀態',
+        duration: '時長',
+        filament: '耗材',
+        cost: '成本',
+      },
+      status: {
+        completed: '已完成',
+        failed: '失敗',
+        cancelled: '已取消',
+        stopped: '已停止',
+        skipped: '已略過',
+        printing: '列印中',
+      },
+    },
     modal: {
     modal: {
       deleteArchive: '刪除歸檔',
       deleteArchive: '刪除歸檔',
       deleteConfirm: '確定要刪除"{{name}}"嗎?此操作無法復原。',
       deleteConfirm: '確定要刪除"{{name}}"嗎?此操作無法復原。',

+ 48 - 0
frontend/src/pages/ArchivesPage.tsx

@@ -55,6 +55,7 @@ import {
   Zap,
   Zap,
   Cog,
   Cog,
   Archive as ArchiveIcon,
   Archive as ArchiveIcon,
+  History,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { SliceModal } from '../components/SliceModal';
 import { SliceModal } from '../components/SliceModal';
@@ -71,6 +72,7 @@ import { UploadModal } from '../components/UploadModal';
 import { PurgeArchivesModal } from '../components/PurgeArchivesModal';
 import { PurgeArchivesModal } from '../components/PurgeArchivesModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { EditArchiveModal } from '../components/EditArchiveModal';
 import { EditArchiveModal } from '../components/EditArchiveModal';
+import { PrintLogModal } from '../components/PrintLogModal';
 import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu';
 import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu';
 import { BatchTagModal } from '../components/BatchTagModal';
 import { BatchTagModal } from '../components/BatchTagModal';
 import { BatchProjectModal } from '../components/BatchProjectModal';
 import { BatchProjectModal } from '../components/BatchProjectModal';
@@ -185,6 +187,7 @@ function ArchiveCard({
   // off — soft delete preserves the archive's filament/time/cost contribution.
   // off — soft delete preserves the archive's filament/time/cost contribution.
   const [deletePurgeStats, setDeletePurgeStats] = useState(false);
   const [deletePurgeStats, setDeletePurgeStats] = useState(false);
   const [showEdit, setShowEdit] = useState(false);
   const [showEdit, setShowEdit] = useState(false);
+  const [showPrintLog, setShowPrintLog] = useState(false);
   const [showTimelapse, setShowTimelapse] = useState(false);
   const [showTimelapse, setShowTimelapse] = useState(false);
   const [showTimelapseSelect, setShowTimelapseSelect] = useState(false);
   const [showTimelapseSelect, setShowTimelapseSelect] = useState(false);
   const [availableTimelapses, setAvailableTimelapses] = useState<Array<{ name: string; path: string; size: number; mtime: string | null }>>([]);
   const [availableTimelapses, setAvailableTimelapses] = useState<Array<{ name: string; path: string; size: number; mtime: string | null }>>([]);
@@ -583,6 +586,11 @@ function ArchiveCard({
       disabled: !canModify('archives', 'update', archive.created_by_id),
       disabled: !canModify('archives', 'update', archive.created_by_id),
       title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
       title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
     },
     },
+    {
+      label: t('archives.menu.printLog'),
+      icon: <History className="w-4 h-4" />,
+      onClick: () => setShowPrintLog(true),
+    },
     ...(archive.project_id && archive.project_name ? [{
     ...(archive.project_id && archive.project_name ? [{
       label: t('archives.menu.goToProject', { name: archive.project_name }),
       label: t('archives.menu.goToProject', { name: archive.project_name }),
       icon: <FolderKanban className="w-4 h-4 text-bambu-green" />,
       icon: <FolderKanban className="w-4 h-4 text-bambu-green" />,
@@ -973,6 +981,22 @@ function ArchiveCard({
               {archive.project_name}
               {archive.project_name}
             </span>
             </span>
           )}
           )}
+          {archive.run_count > 1 && (
+            <button
+              className="text-[10px] px-1.5 py-0.5 rounded font-medium bg-bambu-orange/20 text-bambu-orange hover:bg-bambu-orange/30 transition-colors cursor-pointer"
+              onClick={(e) => {
+                e.stopPropagation();
+                setShowPrintLog(true);
+              }}
+              title={t('archives.card.runsBadgeTitle', {
+                count: archive.run_count,
+                successful: archive.successful_run_count,
+                failed: archive.failed_run_count,
+              })}
+            >
+              {t('archives.card.runsBadge', { count: archive.run_count })}
+            </button>
+          )}
         </div>
         </div>
 
 
         {/* Stats */}
         {/* Stats */}
@@ -1217,6 +1241,15 @@ function ArchiveCard({
         />
         />
       )}
       )}
 
 
+      {/* Print Log Modal — opened from the "N prints" badge or context menu (#1378) */}
+      {showPrintLog && (
+        <PrintLogModal
+          archiveId={archive.id}
+          archiveName={archive.print_name || archive.filename}
+          onClose={() => setShowPrintLog(false)}
+        />
+      )}
+
       {/* Plate picker — shown only for multi-plate archives on 3D Preview click */}
       {/* Plate picker — shown only for multi-plate archives on 3D Preview click */}
       {platePickerPlates && (
       {platePickerPlates && (
         <PlatePickerModal
         <PlatePickerModal
@@ -1525,6 +1558,7 @@ function ArchiveListRow({
   const { showToast } = useToast();
   const { showToast } = useToast();
   const { hasPermission, canModify } = useAuth();
   const { hasPermission, canModify } = useAuth();
   const [showEdit, setShowEdit] = useState(false);
   const [showEdit, setShowEdit] = useState(false);
+  const [showPrintLog, setShowPrintLog] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   // #1343: opt-in "Also remove from statistics" checkbox state. Default off
   // #1343: opt-in "Also remove from statistics" checkbox state. Default off
   // — soft delete keeps the archive's contribution to Quick Stats.
   // — soft delete keeps the archive's contribution to Quick Stats.
@@ -1905,6 +1939,11 @@ function ArchiveListRow({
       disabled: !canModify('archives', 'update', archive.created_by_id),
       disabled: !canModify('archives', 'update', archive.created_by_id),
       title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
       title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
     },
     },
+    {
+      label: t('archives.menu.printLog'),
+      icon: <History className="w-4 h-4" />,
+      onClick: () => setShowPrintLog(true),
+    },
     ...(archive.project_id && archive.project_name ? [{
     ...(archive.project_id && archive.project_name ? [{
       label: t('archives.menu.goToProject', { name: archive.project_name }),
       label: t('archives.menu.goToProject', { name: archive.project_name }),
       icon: <FolderKanban className="w-4 h-4 text-bambu-green" />,
       icon: <FolderKanban className="w-4 h-4 text-bambu-green" />,
@@ -2188,6 +2227,15 @@ function ArchiveListRow({
         />
         />
       )}
       )}
 
 
+      {/* Print Log Modal — opened from the "N prints" badge or context menu (#1378) */}
+      {showPrintLog && (
+        <PrintLogModal
+          archiveId={archive.id}
+          archiveName={archive.print_name || archive.filename}
+          onClose={() => setShowPrintLog(false)}
+        />
+      )}
+
       {/* Plate picker — shown only for multi-plate archives on 3D Preview click */}
       {/* Plate picker — shown only for multi-plate archives on 3D Preview click */}
       {platePickerPlates && (
       {platePickerPlates && (
         <PlatePickerModal
         <PlatePickerModal

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


+ 1 - 1
static/index.html

@@ -26,7 +26,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-lshkWq_8.js"></script>
+    <script type="module" crossorigin src="/assets/index-2Ag-0qip.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Baw5c3Hn.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Baw5c3Hn.css">
   </head>
   </head>
   <body>
   <body>

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