mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-29 18:51:43 +02:00
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:
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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}}" を削除しますか?この操作は取り消せません。',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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}}"吗?此操作无法撤销。',
|
||||
|
||||
@@ -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}}"嗎?此操作無法復原。',
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user