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.
This commit is contained in:
maziggy
2026-05-16 11:34:40 +02:00
parent 8e241915ae
commit 856b849ffa
30 changed files with 1697 additions and 524 deletions
+2
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
@@ -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
@@ -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
@@ -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
View File
@@ -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;
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 one or more lines are too long
+1 -1
View File
@@ -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>