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)
 - Archive comparison (side-by-side diff)
 - 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.
 
 ### 📊 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.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 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.user import User
 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.services.archive import ArchiveService
 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)
 
 
+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:
     """Compute actual print time and accuracy for an archive.
 
@@ -163,12 +175,48 @@ def compute_time_accuracy(archive: PrintArchive) -> dict:
     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(
     archive: PrintArchive,
     duplicates: list[dict] | None = None,
     duplicate_count: int = 0,
     duplicate_sequence: int = 0,
     original_archive_id: int | None = None,
+    run_aggregate: dict | None = None,
 ) -> dict:
     """Convert archive model to response dict with computed fields."""
     data = {
@@ -226,6 +274,13 @@ def archive_to_response(
     accuracy_data = compute_time_accuracy(archive)
     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
 
 
@@ -318,6 +373,8 @@ async def list_archives(
             for sequence, (archive_id, _) in enumerate(group):
                 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
     result = []
     for a in archives:
@@ -342,6 +399,7 @@ async def list_archives(
                 duplicate_count=duplicate_count,
                 duplicate_sequence=duplicate_sequence,
                 original_archive_id=original_archive_id,
+                run_aggregate=run_aggregates.get(a.id),
             )
         )
     return result
@@ -762,69 +820,75 @@ async def get_archive_stats(
     db: AsyncSession = Depends(get_db),
     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)
 
-    # Build date filter conditions
+    # Build date filter conditions scoped to PrintLogEntry (event-time).
     base_conditions = []
     if date_from:
         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:
         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
 
     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
 
     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
 
-    # 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
-    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
 
-    # Sum filament directly - filament_used_grams already contains the total for the print job
     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
 
-    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
 
     # By filament type (split comma-separated values for multi-material prints)
     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] = {}
     for (filament_types,) in filament_type_result.all():
-        # Split by comma and count each type
         for ftype in filament_types.split(","):
             ftype = ftype.strip()
             if ftype:
@@ -832,47 +896,49 @@ async def get_archive_stats(
 
     # By printer
     printer_result = await db.execute(
-        select(PrintArchive.printer_id, func.count(PrintArchive.id))
+        select(PrintLogEntry.printer_id, func.count(PrintLogEntry.id))
         .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()}
 
-    # 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
     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
     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
     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
 
-        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
 
     return ArchiveStats(
@@ -1178,7 +1244,35 @@ async def get_archive(
         print_name=archive.print_name,
         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")
@@ -1571,6 +1665,17 @@ async def delete_archive(
 
     service = ArchiveService(db)
     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):
             raise HTTPException(404, "Archive not found")
         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.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.printer import Printer
 from backend.app.models.settings import Settings
@@ -352,11 +352,13 @@ async def get_metrics(
     # 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("# HELP bambuddy_prints_total Total number of prints by result")
     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():
         result_label = print_result or "unknown"
         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("# TYPE bambuddy_printer_prints_total counter")
     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():
         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}")
 
-    # 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("# HELP bambuddy_filament_used_grams Total filament used in grams")
     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
     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("# HELP bambuddy_print_time_seconds Total print time in seconds")
     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
     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,
         )
 
+    # 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():
     """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:
     """Resolve AMS mapping for print start without consuming stored queue/reprint state."""
     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:
                     archive.created_by_id = _print_user_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(
                     db,
-                    status=data.get("status", "completed"),
+                    archive_id=archive.id,
+                    status=_run_status,
                     print_name=archive.print_name,
                     printer_name=p_info.name if p_info else None,
                     printer_id=printer_id,
@@ -3555,8 +3609,11 @@ async def on_print_complete(printer_id: int, data: dict):
                     completed_at=archive.completed_at,
                     filament_type=archive.filament_type,
                     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,
+                    created_by_id=archive.created_by_id,
                     created_by_username=_print_user_info.get("username") if _print_user_info else None,
                 )
                 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")
                 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()
-                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:
             logger.warning("[ENERGY-BG] Failed: %s", e)
 

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

@@ -1,6 +1,6 @@
 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 backend.app.core.database import Base
@@ -11,11 +11,19 @@ class PrintLogEntry(Base):
 
     This is a separate table from archives/queue — clearing the log
     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"
 
     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))
     printer_name: Mapped[str | None] = mapped_column(String(255))
     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_color: Mapped[str | None] = mapped_column(String(50))
     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))
+    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_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_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")
     def compute_object_count(self) -> "ArchiveResponse":
         """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):
     id: int
+    archive_id: int | None = None
     print_name: str | None = None
     printer_name: str | None = None
     printer_id: int | None = None
@@ -15,7 +16,12 @@ class PrintLogEntrySchema(BaseModel):
     filament_type: str | None = None
     filament_color: str | 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
+    created_by_id: int | None = None
     created_by_username: str | None = None
     created_at: datetime
 

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

@@ -17,6 +17,7 @@ async def write_log_entry(
     db: AsyncSession,
     *,
     status: str,
+    archive_id: int | None = None,
     print_name: str | None = None,
     printer_name: str | None = None,
     printer_id: int | None = None,
@@ -25,7 +26,12 @@ async def write_log_entry(
     filament_type: str | None = None,
     filament_color: str | 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,
+    created_by_id: int | None = None,
     created_by_username: str | None = None,
 ) -> PrintLogEntry:
     """Write a print log entry."""
@@ -34,6 +40,7 @@ async def write_log_entry(
         duration = int((completed_at - started_at).total_seconds())
 
     entry = PrintLogEntry(
+        archive_id=archive_id,
         print_name=print_name,
         printer_name=printer_name,
         printer_id=printer_id,
@@ -44,7 +51,12 @@ async def write_log_entry(
         filament_type=filament_type,
         filament_color=filament_color,
         filament_used_grams=filament_used_grams,
+        cost=cost,
+        energy_kwh=energy_kwh,
+        energy_cost=energy_cost,
+        failure_reason=failure_reason,
         thumbnail_path=thumbnail_path,
+        created_by_id=created_by_id,
         created_by_username=created_by_username,
     )
     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.
 
     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.print_log import PrintLogEntry
 
         archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
         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:
                 total_cost += (untracked_grams / 1000.0) * default_filament_cost
             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
 

+ 38 - 1
backend/tests/conftest.py

@@ -116,6 +116,7 @@ async def test_engine():
         notification,
         notification_template,
         oidc_provider,
+        print_log,
         print_queue,
         printer,
         project,
@@ -504,10 +505,21 @@ def notification_provider_factory(db_session):
 
 @pytest.fixture
 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):
         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 = {
             "printer_id": printer_id,
@@ -526,6 +538,31 @@ def archive_factory(db_session):
         db_session.add(archive)
         await db_session.commit()
         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 _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)
   created_by_id: number | 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 {
@@ -507,6 +513,7 @@ export interface ArchiveSlim {
 
 export interface PrintLogEntry {
   id: number;
+  archive_id: number | null;
   print_name: string | null;
   printer_name: string | null;
   printer_id: number | null;
@@ -517,7 +524,12 @@ export interface PrintLogEntry {
   filament_type: string | null;
   filament_color: string | 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;
+  created_by_id: number | null;
   created_by_username: string | null;
   created_at: string;
 }
@@ -3445,6 +3457,7 @@ export const api = {
     return request<ArchiveSlim[]>(`/archives/slim${qs ? `?${qs}` : ''}`);
   },
   getArchive: (id: number) => request<Archive>(`/archives/${id}`),
+  getArchiveRuns: (id: number) => request<PrintLogResponse>(`/archives/${id}/runs`),
   searchArchives: (query: string, options?: {
     printerId?: 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 type { Archive } from '../api/client';
 import { Button } from './Button';
+import { PrintLogTable } from './PrintLogTable';
 
 // Keys for failure reasons - translated at render time
 const FAILURE_REASON_KEYS = [
@@ -217,6 +218,13 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
 
         {/* Form */}
         <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 */}
           <div>
             <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',
       removeFromFavorites: 'Aus Favoriten entfernen',
       edit: 'Bearbeiten',
+      printLog: 'Druckprotokoll',
       goToProject: 'Zum Projekt: {{name}}',
       addToProject: 'Zu Projekt hinzufügen',
       removeFromProject: 'Aus Projekt entfernen',
@@ -786,6 +787,8 @@ export default {
       gcode: 'GCODE',
       source: 'QUELLE',
       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}}',
       actual: 'Tatsächlich: {{time}}',
       accuracy: 'Genauigkeit: {{percent}}%',
@@ -815,6 +818,27 @@ export default {
       edit: 'Bearbeiten',
       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: {
       deleteArchive: 'Archiv löschen',
       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',
       removeFromFavorites: 'Remove from Favorites',
       edit: 'Edit',
+      printLog: 'Print Log',
       goToProject: 'Go to Project: {{name}}',
       addToProject: 'Add to Project',
       removeFromProject: 'Remove from Project',
@@ -786,6 +787,8 @@ export default {
       gcode: 'GCODE',
       source: 'SOURCE',
       project: 'Project: {{name}}',
+      runsBadge: '{{count}} prints',
+      runsBadgeTitle: '{{count}} prints total — {{successful}} successful, {{failed}} failed. Click to see the full print log.',
       estimated: 'Estimated: {{time}}',
       actual: 'Actual: {{time}}',
       accuracy: 'Accuracy: {{percent}}%',
@@ -815,6 +818,27 @@ export default {
       edit: 'Edit',
       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: {
       deleteArchive: 'Delete Archive',
       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',
       removeFromFavorites: 'Retirer des favoris',
       edit: 'Modifier',
+      printLog: 'Journal d\'impression',
       goToProject: 'Aller au Projet : {{name}}',
       addToProject: 'Ajouter au Projet',
       removeFromProject: 'Retirer du Projet',
@@ -786,6 +787,8 @@ export default {
       gcode: 'GCODE',
       source: 'SOURCE',
       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}}',
       actual: 'Réel : {{time}}',
       accuracy: 'Précision : {{percent}}%',
@@ -815,6 +818,27 @@ export default {
       edit: 'Modifier',
       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: {
       deleteArchive: 'Supprimer l\'archive',
       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',
       removeFromFavorites: 'Rimuovi dai preferiti',
       edit: 'Modifica',
+      printLog: 'Registro stampe',
       goToProject: 'Vai al progetto: {{name}}',
       addToProject: 'Aggiungi al progetto',
       removeFromProject: 'Rimuovi dal progetto',
@@ -786,6 +787,8 @@ export default {
       gcode: 'GCODE',
       source: 'SOURCE',
       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}}',
       actual: 'Reale: {{time}}',
       accuracy: 'Accuratezza: {{percent}}%',
@@ -815,6 +818,27 @@ export default {
       edit: 'Modifica',
       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: {
       deleteArchive: 'Elimina Archivio',
       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: 'お気に入りに追加',
       removeFromFavorites: 'お気に入りから削除',
       edit: '編集',
+      printLog: '印刷ログ',
       goToProject: 'プロジェクトへ: {{name}}',
       addToProject: 'プロジェクトに追加',
       removeFromProject: 'プロジェクトから削除',
@@ -785,6 +786,8 @@ export default {
       gcode: 'GCODE',
       source: 'ソース',
       project: 'プロジェクト: {{name}}',
+      runsBadge: '{{count}} 回印刷',
+      runsBadgeTitle: '合計 {{count}} 回 — 成功 {{successful}} 回、失敗 {{failed}} 回。クリックして印刷ログを開きます。',
       estimated: '推定: {{time}}',
       actual: '実際: {{time}}',
       accuracy: '精度: {{percent}}%',
@@ -814,6 +817,27 @@ export default {
       edit: '編集',
       delete: '削除',
     },
+    runLog: {
+      title: '印刷ログ',
+      modalTitle: '印刷ログ — {{name}}',
+      modalTitleFallback: 'このアーカイブ',
+      empty: 'このアーカイブの印刷イベントはまだ記録されていません。',
+      col: {
+        date: '日付',
+        status: 'ステータス',
+        duration: '時間',
+        filament: 'フィラメント',
+        cost: 'コスト',
+      },
+      status: {
+        completed: '完了',
+        failed: '失敗',
+        cancelled: 'キャンセル',
+        stopped: '停止',
+        skipped: 'スキップ',
+        printing: '印刷中',
+      },
+    },
     modal: {
       deleteArchive: 'アーカイブを削除',
       deleteConfirm: '"{{name}}" を削除しますか?この操作は取り消せません。',

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

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

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

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

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

@@ -55,6 +55,7 @@ import {
   Zap,
   Cog,
   Archive as ArchiveIcon,
+  History,
 } from 'lucide-react';
 import { api } from '../api/client';
 import { SliceModal } from '../components/SliceModal';
@@ -71,6 +72,7 @@ import { UploadModal } from '../components/UploadModal';
 import { PurgeArchivesModal } from '../components/PurgeArchivesModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { EditArchiveModal } from '../components/EditArchiveModal';
+import { PrintLogModal } from '../components/PrintLogModal';
 import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu';
 import { BatchTagModal } from '../components/BatchTagModal';
 import { BatchProjectModal } from '../components/BatchProjectModal';
@@ -185,6 +187,7 @@ function ArchiveCard({
   // off — soft delete preserves the archive's filament/time/cost contribution.
   const [deletePurgeStats, setDeletePurgeStats] = useState(false);
   const [showEdit, setShowEdit] = useState(false);
+  const [showPrintLog, setShowPrintLog] = useState(false);
   const [showTimelapse, setShowTimelapse] = useState(false);
   const [showTimelapseSelect, setShowTimelapseSelect] = useState(false);
   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),
       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 ? [{
       label: t('archives.menu.goToProject', { name: archive.project_name }),
       icon: <FolderKanban className="w-4 h-4 text-bambu-green" />,
@@ -973,6 +981,22 @@ function ArchiveCard({
               {archive.project_name}
             </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>
 
         {/* 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 */}
       {platePickerPlates && (
         <PlatePickerModal
@@ -1525,6 +1558,7 @@ function ArchiveListRow({
   const { showToast } = useToast();
   const { hasPermission, canModify } = useAuth();
   const [showEdit, setShowEdit] = useState(false);
+  const [showPrintLog, setShowPrintLog] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   // #1343: opt-in "Also remove from statistics" checkbox state. Default off
   // — soft delete keeps the archive's contribution to Quick Stats.
@@ -1905,6 +1939,11 @@ function ArchiveListRow({
       disabled: !canModify('archives', 'update', archive.created_by_id),
       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 ? [{
       label: t('archives.menu.goToProject', { name: archive.project_name }),
       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 */}
       {platePickerPlates && (
         <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 -->
     <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">
   </head>
   <body>

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