Browse Source

Issue 1: Aborted prints showing "Unknown" in failure analysis
- The failure analysis service was only querying status == "failed"
- Changed all queries to include both failed AND aborted: status.in_(["failed", "aborted"])
- File: backend/app/services/failure_analysis.py

Issue 2: Cannot clear failed flag in edit mode
- Added status field to ArchiveUpdate schema (backend)
- Added status to API client type (frontend)
- Added status dropdown to EditArchiveModal with options: Completed, Failed, Cancelled, Printing
- When status changes to "completed", failure_reason is automatically cleared
- File changes:
- backend/app/schemas/archive.py - added status field
- frontend/src/api/client.ts - added status to updateArchive type
- frontend/src/components/EditArchiveModal.tsx - added status dropdown

maziggy 5 months ago
parent
commit
adcefd3a5b

+ 4 - 1
backend/app/api/routes/archives.py

@@ -39,7 +39,10 @@ def compute_time_accuracy(archive: PrintArchive) -> dict:
             if archive.print_time_seconds and archive.print_time_seconds > 0:
                 # Calculate accuracy as percentage
                 accuracy = (archive.print_time_seconds / actual_seconds) * 100
-                result["time_accuracy"] = round(accuracy, 1)
+                # Sanity check: skip unreasonable values (e.g., manually changed status)
+                # Valid range: 5% to 500% (print took 20x longer to 5x faster than estimated)
+                if 5 <= accuracy <= 500:
+                    result["time_accuracy"] = round(accuracy, 1)
 
     return result
 

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

@@ -1,4 +1,5 @@
 from datetime import datetime
+
 from pydantic import BaseModel
 
 
@@ -14,10 +15,12 @@ class ArchiveBase(BaseModel):
 class ArchiveUpdate(ArchiveBase):
     printer_id: int | None = None
     project_id: int | None = None
+    status: str | None = None  # Allow changing status (e.g., clearing failed flag)
 
 
 class ArchiveDuplicate(BaseModel):
     """Reference to a duplicate archive."""
+
     id: int
     print_name: str | None
     created_at: datetime
@@ -99,6 +102,7 @@ class ArchiveStats(BaseModel):
 
 class ProjectPageImage(BaseModel):
     """Image embedded in 3MF project page."""
+
     name: str
     path: str  # Path within 3MF
     url: str  # API URL to fetch image
@@ -106,6 +110,7 @@ class ProjectPageImage(BaseModel):
 
 class ProjectPageResponse(BaseModel):
     """Project page data extracted from 3MF file."""
+
     # Model info
     title: str | None = None
     description: str | None = None  # HTML content
@@ -137,6 +142,7 @@ class ProjectPageResponse(BaseModel):
 
 class ProjectPageUpdate(BaseModel):
     """Update project page data in 3MF file."""
+
     title: str | None = None
     description: str | None = None
     designer: str | None = None

+ 25 - 36
backend/app/services/failure_analysis.py

@@ -1,7 +1,8 @@
-from datetime import datetime, timedelta
 from collections import defaultdict
+from datetime import datetime, timedelta
+
+from sqlalchemy import and_, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select, func, and_
 
 from backend.app.models.archive import PrintArchive
 from backend.app.models.printer import Printer
@@ -39,14 +40,12 @@ class FailureAnalysisService:
             base_filter.append(PrintArchive.project_id == project_id)
 
         # Total counts
-        total_result = await self.db.execute(
-            select(func.count(PrintArchive.id)).where(and_(*base_filter))
-        )
+        total_result = await self.db.execute(select(func.count(PrintArchive.id)).where(and_(*base_filter)))
         total_prints = total_result.scalar() or 0
 
         failed_result = await self.db.execute(
             select(func.count(PrintArchive.id)).where(
-                and_(*base_filter, PrintArchive.status == "failed")
+                and_(*base_filter, PrintArchive.status.in_(["failed", "aborted"]))
             )
         )
         failed_prints = failed_result.scalar() or 0
@@ -59,14 +58,11 @@ class FailureAnalysisService:
                 PrintArchive.failure_reason,
                 func.count(PrintArchive.id).label("count"),
             )
-            .where(and_(*base_filter, PrintArchive.status == "failed"))
+            .where(and_(*base_filter, PrintArchive.status.in_(["failed", "aborted"])))
             .group_by(PrintArchive.failure_reason)
             .order_by(func.count(PrintArchive.id).desc())
         )
-        failures_by_reason = {
-            (row[0] or "Unknown"): row[1]
-            for row in reason_result.fetchall()
-        }
+        failures_by_reason = {(row[0] or "Unknown"): row[1] for row in reason_result.fetchall()}
 
         # Failures by filament type
         filament_result = await self.db.execute(
@@ -74,14 +70,11 @@ class FailureAnalysisService:
                 PrintArchive.filament_type,
                 func.count(PrintArchive.id).label("count"),
             )
-            .where(and_(*base_filter, PrintArchive.status == "failed"))
+            .where(and_(*base_filter, PrintArchive.status.in_(["failed", "aborted"])))
             .group_by(PrintArchive.filament_type)
             .order_by(func.count(PrintArchive.id).desc())
         )
-        failures_by_filament = {
-            (row[0] or "Unknown"): row[1]
-            for row in filament_result.fetchall()
-        }
+        failures_by_filament = {(row[0] or "Unknown"): row[1] for row in filament_result.fetchall()}
 
         # Failures by printer
         printer_result = await self.db.execute(
@@ -90,7 +83,7 @@ class FailureAnalysisService:
                 func.count(PrintArchive.id).label("count"),
             )
             .where(
-                and_(*base_filter, PrintArchive.status == "failed", PrintArchive.printer_id.isnot(None))
+                and_(*base_filter, PrintArchive.status.in_(["failed", "aborted"]), PrintArchive.printer_id.isnot(None))
             )
             .group_by(PrintArchive.printer_id)
             .order_by(func.count(PrintArchive.id).desc())
@@ -100,25 +93,21 @@ class FailureAnalysisService:
         # Get printer names
         if failures_by_printer_id:
             printers_result = await self.db.execute(
-                select(Printer.id, Printer.name).where(
-                    Printer.id.in_(failures_by_printer_id.keys())
-                )
+                select(Printer.id, Printer.name).where(Printer.id.in_(failures_by_printer_id.keys()))
             )
             printer_names = {row[0]: row[1] for row in printers_result.fetchall()}
             failures_by_printer = {
-                printer_names.get(pid, f"Printer {pid}"): count
-                for pid, count in failures_by_printer_id.items()
+                printer_names.get(pid, f"Printer {pid}"): count for pid, count in failures_by_printer_id.items()
             }
         else:
             failures_by_printer = {}
 
         # Failures by hour of day
         failed_archives_result = await self.db.execute(
-            select(PrintArchive.started_at)
-            .where(
+            select(PrintArchive.started_at).where(
                 and_(
                     *base_filter,
-                    PrintArchive.status == "failed",
+                    PrintArchive.status.in_(["failed", "aborted"]),
                     PrintArchive.started_at.isnot(None),
                 )
             )
@@ -134,7 +123,7 @@ class FailureAnalysisService:
         # Recent failures
         recent_result = await self.db.execute(
             select(PrintArchive)
-            .where(and_(*base_filter, PrintArchive.status == "failed"))
+            .where(and_(*base_filter, PrintArchive.status.in_(["failed", "aborted"])))
             .order_by(PrintArchive.created_at.desc())
             .limit(10)
         )
@@ -162,12 +151,10 @@ class FailureAnalysisService:
                 PrintArchive.created_at < week_end,
             )
 
-            week_total = await self.db.execute(
-                select(func.count(PrintArchive.id)).where(and_(*week_filter))
-            )
+            week_total = await self.db.execute(select(func.count(PrintArchive.id)).where(and_(*week_filter)))
             week_failed = await self.db.execute(
                 select(func.count(PrintArchive.id)).where(
-                    and_(*week_filter, PrintArchive.status == "failed")
+                    and_(*week_filter, PrintArchive.status.in_(["failed", "aborted"]))
                 )
             )
 
@@ -175,12 +162,14 @@ class FailureAnalysisService:
             failed = week_failed.scalar() or 0
             rate = (failed / total * 100) if total > 0 else 0
 
-            trend_data.append({
-                "week_start": week_start.date().isoformat(),
-                "total_prints": total,
-                "failed_prints": failed,
-                "failure_rate": round(rate, 1),
-            })
+            trend_data.append(
+                {
+                    "week_start": week_start.date().isoformat(),
+                    "total_prints": total,
+                    "failed_prints": failed,
+                    "failure_rate": round(rate, 1),
+                }
+            )
 
         trend_data.reverse()  # Oldest first
 

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

@@ -1206,6 +1206,7 @@ export const api = {
     notes?: string;
     cost?: number;
     failure_reason?: string | null;
+    status?: string;
   }) =>
     request<Archive>(`/archives/${id}`, {
       method: 'PATCH',

+ 49 - 4
frontend/src/components/EditArchiveModal.tsx

@@ -19,6 +19,13 @@ const FAILURE_REASONS = [
   'Other',
 ];
 
+const ARCHIVE_STATUSES = [
+  { value: 'completed', label: 'Completed' },
+  { value: 'failed', label: 'Failed' },
+  { value: 'aborted', label: 'Cancelled' },
+  { value: 'printing', label: 'Printing' },
+];
+
 interface EditArchiveModalProps {
   archive: Archive;
   onClose: () => void;
@@ -41,6 +48,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
   const [notes, setNotes] = useState(archive.notes || '');
   const [tags, setTags] = useState(archive.tags || '');
   const [failureReason, setFailureReason] = useState(archive.failure_reason || '');
+  const [status, setStatus] = useState(archive.status);
   const [photos, setPhotos] = useState<string[]>(archive.photos || []);
   const [uploadingPhoto, setUploadingPhoto] = useState(false);
   const [showTagSuggestions, setShowTagSuggestions] = useState(false);
@@ -138,14 +146,29 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
 
   const handleSubmit = (e: React.FormEvent) => {
     e.preventDefault();
-    updateMutation.mutate({
+    // Build update data
+    const updateData: Parameters<typeof api.updateArchive>[1] = {
       print_name: printName || undefined,
       printer_id: printerId,
       project_id: projectId,
       notes: notes || undefined,
       tags: tags || undefined,
-      failure_reason: (archive.status === 'failed' || archive.status === 'aborted') ? (failureReason || undefined) : undefined,
-    });
+    };
+
+    // Only include status if changed
+    if (status !== archive.status) {
+      updateData.status = status;
+    }
+
+    // Handle failure_reason based on status
+    if (status === 'failed' || status === 'aborted') {
+      updateData.failure_reason = failureReason || undefined;
+    } else if (archive.status === 'failed' || archive.status === 'aborted') {
+      // Clear failure_reason when changing from failed/aborted to another status
+      updateData.failure_reason = null;
+    }
+
+    updateMutation.mutate(updateData);
   };
 
   return (
@@ -297,8 +320,30 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
             </div>
           </div>
 
+          {/* Status */}
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1">Status</label>
+            <select
+              value={status}
+              onChange={(e) => {
+                setStatus(e.target.value);
+                // Clear failure reason when changing to completed
+                if (e.target.value === 'completed') {
+                  setFailureReason('');
+                }
+              }}
+              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+            >
+              {ARCHIVE_STATUSES.map((s) => (
+                <option key={s.value} value={s.value}>
+                  {s.label}
+                </option>
+              ))}
+            </select>
+          </div>
+
           {/* Failure Reason - only show for failed/aborted prints */}
-          {(archive.status === 'failed' || archive.status === 'aborted') && (
+          {(status === 'failed' || status === 'aborted') && (
             <div>
               <label className="block text-sm text-bambu-gray mb-1">Failure Reason</label>
               <select

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


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DWSa7F3W.js"></script>
+    <script type="module" crossorigin src="/assets/index-BScx-4hV.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-CbCN6LSA.css">
   </head>
   <body>

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