Browse Source

Add next-available printer indicator and fix bulk archive delete race condition (#354)

Status summary bar on the Printers page now shows availability count
("X available") and a "Next available" indicator with printer name,
progress bar, percentage, and remaining time for the printer finishing
soonest. Always visible when printers are printing; hidden otherwise.

Fix race condition in bulk archive deletion where files were removed
from disk before the database commit. Concurrent SQLite writes could
cause a lock timeout, rolling back the DB delete while files were
already gone — leaving orphaned records with broken thumbnails. Now
deletes the DB record first and only removes files after a successful
commit.
maziggy 3 months ago
parent
commit
cf91531aa2

+ 2 - 0
CHANGELOG.md

@@ -8,9 +8,11 @@ All notable changes to Bambuddy will be documented in this file.
 - **Spool Inventory — AMS Slot Assignment** — Assign inventory spools to AMS slots for filament tracking. Hover over any non-Bambu-Lab AMS slot to assign or unassign spools. The assign modal filters out Bambu Lab spools (tracked via RFID) and spools already assigned to other slots. Bambu Lab spool slots automatically hide assign/unassign UI since they are managed by the AMS. When a Bambu Lab spool is inserted into a slot with a manual assignment, the assignment is automatically unlinked.
 - **Spool Inventory — Remaining Weight Editing** — Edit the remaining filament weight when adding or editing a spool. The new "Remaining Weight" field in the Additional section shows current weight (label weight minus consumed) with a max reference. Edits are stored as `weight_used` internally.
 - **Spool Inventory — 3MF-Based Usage Tracking for Non-BL Spools** — Non-Bambu-Lab spools (no RFID) cannot use AMS remain% for usage tracking. Now falls back to per-filament weight estimates from the archived 3MF file (`used_g` per filament slot). For completed prints, uses the full slicer estimate. For failed or aborted prints, scales by print progress percentage. Bambu Lab spools continue using AMS remain% delta tracking as before.
+- **Printer Status Summary Bar — Next Available & Availability Count** ([#354](https://github.com/maziggy/bambuddy/issues/354)) — The status bar on the Printers page now shows an availability count ("X available") alongside the printing/offline counts, and a "Next available" indicator showing which printing printer will finish soonest — with printer name, mini progress bar, completion percentage, and remaining time. Useful for print farms to quickly identify the next free printer. Updates in real-time via WebSocket. Translated in all 4 locales (en, de, ja, it).
 - **Nozzle-Aware AMS Filament Mapping for Dual-Nozzle Printers** ([#318](https://github.com/maziggy/bambuddy/issues/318)) — On dual-nozzle printers (H2D, H2D Pro), each AMS unit is physically connected to either the left or right nozzle. Bambuddy now reads nozzle assignments from the 3MF file (`filament_nozzle_map` + `physical_extruder_map` in `project_settings.config`) and constrains filament matching to only AMS trays connected to the correct nozzle via `ams_extruder_map`. Applies to the print scheduler, reprint modal, queue modal, and multi-printer selection. Falls back gracefully to unfiltered matching when no trays exist on the target nozzle. The filament mapping UI shows L/R nozzle badges for dual-nozzle prints. Translated in all 4 locales (en, de, ja, it).
 
 ### Fixed
+- **Bulk Archive Delete Leaves Orphaned Database Records** — When bulk-deleting archives, the files were removed from disk before the database commit. If concurrent SQLite writes caused a lock timeout, the commit failed and rolled back — leaving database records pointing to deleted files (broken thumbnails, 404 errors). Fixed by deleting the database record first and only removing files after a successful commit.
 - **Model-Specific Maintenance Tasks for Carbon Rods vs Linear Rails** ([#351](https://github.com/maziggy/bambuddy/issues/351)) — Maintenance tasks "Clean Carbon Rods" and "Lubricate Linear Rails" were shown for all printers regardless of motion system. H2 and A1 series use linear rails (not carbon rods), and X1/P1/P2S series use carbon rods (not linear rails). Maintenance types are now classified by rod/rail type: "Lubricate Carbon Rods" and "Clean Carbon Rods" for X1/P1/P2S, "Lubricate Linear Rails" and "Clean Linear Rails" for A1/H2. Stale and duplicate system types are automatically cleaned up on startup. Includes model-specific wiki links and i18n keys for all 4 locales.
 - **AMS Slot Configuration Overwritten on Startup** — Bambuddy was resetting AMS slot filament presets on every startup and reconnection. The `on_ams_change` callback unconditionally unlinked Bambu Lab spool assignments on each MQTT push-all response, then re-assigned them by sending `ams_filament_setting` without a `setting_id`, which cleared the printer's filament preset. Now compares spool RFID identifiers (`tray_uuid` / `tag_uid`) before unlinking — if the same spool is still in the slot, the assignment is preserved and no `ams_filament_setting` command is sent.
 - **Bambu Lab Spool Detection False Positives** — The `is_bambu_lab_spool()` function (backend) and `isBambuLabSpool()` (frontend) incorrectly identified third-party spools as Bambu Lab spools when they used Bambu generic filament presets (e.g., "Generic PLA"). The `tray_info_idx` field (e.g., "GFA00") identifies the filament *type*, not the spool manufacturer — third-party spools using Bambu presets also have GF-prefixed values. Removed `tray_info_idx` from detection logic; now uses only hardware RFID identifiers (`tray_uuid` and `tag_uid`) which are physically embedded in genuine Bambu Lab spools.

+ 11 - 6
backend/app/services/archive.py

@@ -1026,8 +1026,9 @@ class ArchiveService:
         if not archive:
             return False
 
-        # Delete files - with CRITICAL safety checks to prevent accidental deletion
-        # of parent directories (e.g., /opt) if file_path is empty/malformed
+        # Resolve the directory to delete BEFORE committing the DB change
+        dir_to_delete: Path | None = None
+
         if archive.file_path and archive.file_path.strip():
             file_path = settings.base_dir / archive.file_path
             if file_path.exists():
@@ -1041,13 +1042,11 @@ class ArchiveService:
                         f"SECURITY: Refusing to delete archive {archive_id} - "
                         f"path {archive_dir} is outside archive directory {settings.archive_dir}"
                     )
-                    # Still delete the database record, just not the files
                     await self.db.delete(archive)
                     await self.db.commit()
                     return True
 
                 # Safety check 2: archive_dir must be at least 1 level deep inside archive_dir
-                # (should be archive_dir/uuid/file.3mf, so parent should be archive_dir/uuid)
                 try:
                     relative_path = archive_dir.resolve().relative_to(settings.archive_dir.resolve())
                     if len(relative_path.parts) < 1:
@@ -1061,16 +1060,22 @@ class ArchiveService:
                 except ValueError:
                     pass  # Already handled above
 
-                shutil.rmtree(archive_dir, ignore_errors=True)
+                dir_to_delete = archive_dir
         else:
             logger.error(
                 f"SECURITY: Refusing to delete files for archive {archive_id} - "
                 f"file_path is empty or invalid: '{archive.file_path}'"
             )
 
-        # Delete database record
+        # Delete database record FIRST — if the commit fails (e.g. database locked
+        # during concurrent bulk deletes), the files stay on disk and nothing is lost.
         await self.db.delete(archive)
         await self.db.commit()
+
+        # Only delete files AFTER the DB commit succeeds to avoid orphaned records
+        if dir_to_delete:
+            shutil.rmtree(dir_to_delete, ignore_errors=True)
+
         return True
 
     async def attach_timelapse(

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

@@ -124,6 +124,7 @@ export default {
     nozzleCount: 'Düsenanzahl',
     autoArchive: 'Automatische Archivierung',
     status: {
+      available: 'Verfügbar',
       idle: 'Bereit',
       printing: 'Druckt',
       paused: 'Pausiert',
@@ -163,6 +164,7 @@ export default {
     },
     // Controls
     hideOffline: 'Offline ausblenden',
+    nextAvailable: 'Nächster verfügbar',
     powerOn: 'Einschalten',
     offlinePrintersWithPlugs: 'Offline-Drucker mit Smart-Plugs',
     noPrintersConfigured: 'Noch keine Drucker konfiguriert',

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

@@ -124,6 +124,7 @@ export default {
     nozzleCount: 'Nozzle Count',
     autoArchive: 'Auto Archive',
     status: {
+      available: 'Available',
       idle: 'Idle',
       printing: 'Printing',
       paused: 'Paused',
@@ -163,6 +164,7 @@ export default {
     },
     // Controls
     hideOffline: 'Hide offline',
+    nextAvailable: 'Next available',
     powerOn: 'Power On',
     offlinePrintersWithPlugs: 'Offline printers with smart plugs',
     noPrintersConfigured: 'No printers configured yet',

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

@@ -121,6 +121,7 @@ export default {
     nozzleCount: 'Numero Ugelli',
     autoArchive: 'Auto Archiviazione',
     status: {
+      available: 'Disponibile',
       idle: 'Inattiva',
       printing: 'In stampa',
       paused: 'In pausa',
@@ -160,6 +161,7 @@ export default {
     },
     // Controls
     hideOffline: 'Nascondi offline',
+    nextAvailable: 'Prossima disponibile',
     powerOn: 'Accendi',
     offlinePrintersWithPlugs: 'Stampanti offline con smart plug',
     noPrintersConfigured: 'Nessuna stampante configurata',

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

@@ -133,6 +133,7 @@ export default {
     nozzleCount: 'ノズル数',
     autoArchive: '自動アーカイブ',
     status: {
+      available: '利用可能',
       idle: '待機中',
       printing: '印刷中',
       paused: '一時停止',
@@ -167,6 +168,7 @@ export default {
       extraLarge: '特大',
     },
     hideOffline: 'オフラインを非表示',
+    nextAvailable: '次に完了',
     powerOn: '電源オン',
     noPrintersConfigured: 'プリンターが設定されていません',
     readyToPrint: '印刷可能',

+ 58 - 29
frontend/src/pages/PrintersPage.tsx

@@ -1236,14 +1236,34 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
 
-  const counts = useMemo(() => {
+  // Subscribe to query cache changes to re-render when status updates
+  // Throttled to prevent rapid re-renders from causing tab crashes
+  const [cacheTick, setCacheTick] = useState(0);
+  useEffect(() => {
+    let pending = false;
+    const unsubscribe = queryClient.getQueryCache().subscribe(() => {
+      if (!pending) {
+        pending = true;
+        requestAnimationFrame(() => {
+          setCacheTick(t => t + 1);
+          pending = false;
+        });
+      }
+    });
+    return () => unsubscribe();
+  }, [queryClient]);
+
+  const { counts, nextFinish } = useMemo(() => {
     let printing = 0;
     let idle = 0;
     let offline = 0;
     let loading = 0;
+    let nextPrinterName: string | null = null;
+    let nextRemainingMin: number | null = null;
+    let nextProgress: number = 0;
 
     printers?.forEach((printer) => {
-      const status = queryClient.getQueryData<{ connected: boolean; state: string | null }>(['printerStatus', printer.id]);
+      const status = queryClient.getQueryData<{ connected: boolean; state: string | null; remaining_time: number | null; progress: number | null }>(['printerStatus', printer.id]);
       if (status === undefined) {
         // Status not yet loaded - don't count as offline yet
         loading++;
@@ -1251,35 +1271,35 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
         offline++;
       } else if (status.state === 'RUNNING') {
         printing++;
+        if (status.remaining_time != null && status.remaining_time > 0) {
+          if (nextRemainingMin === null || status.remaining_time < nextRemainingMin) {
+            nextRemainingMin = status.remaining_time;
+            nextPrinterName = printer.name;
+            nextProgress = status.progress || 0;
+          }
+        }
       } else {
         idle++;
       }
     });
 
-    return { printing, idle, offline, loading, total: (printers?.length || 0) };
-  }, [printers, queryClient]);
-
-  // Subscribe to query cache changes to re-render when status updates
-  // Throttled to prevent rapid re-renders from causing tab crashes
-  const [, setTick] = useState(0);
-  useEffect(() => {
-    let pending = false;
-    const unsubscribe = queryClient.getQueryCache().subscribe(() => {
-      if (!pending) {
-        pending = true;
-        requestAnimationFrame(() => {
-          setTick(t => t + 1);
-          pending = false;
-        });
-      }
-    });
-    return () => unsubscribe();
-  }, [queryClient]);
+    return {
+      counts: { printing, idle, offline, loading, total: (printers?.length || 0) },
+      nextFinish: nextPrinterName && nextRemainingMin ? { name: nextPrinterName, remainingMin: nextRemainingMin, progress: nextProgress } : null,
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [printers, queryClient, cacheTick]);
 
   if (!printers?.length) return null;
 
   return (
     <div className="flex items-center gap-4 text-sm">
+      <div className="flex items-center gap-1.5">
+        <div className={`w-2 h-2 rounded-full ${counts.idle > 0 ? 'bg-bambu-green' : 'bg-gray-500'}`} />
+        <span className="text-bambu-gray">
+          <span className="text-white font-medium">{counts.idle}</span> {t('printers.status.available').toLowerCase()}
+        </span>
+      </div>
       {counts.printing > 0 && (
         <div className="flex items-center gap-1.5">
           <div className="w-2 h-2 rounded-full bg-bambu-green animate-pulse" />
@@ -1288,14 +1308,6 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
           </span>
         </div>
       )}
-      {counts.idle > 0 && (
-        <div className="flex items-center gap-1.5">
-          <div className="w-2 h-2 rounded-full bg-blue-400" />
-          <span className="text-bambu-gray">
-            <span className="text-white font-medium">{counts.idle}</span> {t('printers.status.idle').toLowerCase()}
-          </span>
-        </div>
-      )}
       {counts.offline > 0 && (
         <div className="flex items-center gap-1.5">
           <div className="w-2 h-2 rounded-full bg-gray-400" />
@@ -1304,6 +1316,23 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
           </span>
         </div>
       )}
+      {nextFinish && (
+        <>
+          <div className="w-px h-4 bg-bambu-dark-tertiary" />
+          <div className="flex items-center gap-2">
+            <span className="text-bambu-green font-medium">{t('printers.nextAvailable')}:</span>
+            <span className="text-white font-medium">{nextFinish.name}</span>
+            <div className="w-16 bg-bambu-dark-tertiary rounded-full h-1.5">
+              <div
+                className="bg-bambu-green h-1.5 rounded-full transition-all"
+                style={{ width: `${nextFinish.progress}%` }}
+              />
+            </div>
+            <span className="text-white font-medium">{Math.round(nextFinish.progress)}%</span>
+            <span className="text-bambu-gray">({formatTime(nextFinish.remainingMin * 60)})</span>
+          </div>
+        </>
+      )}
     </div>
   );
 }

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DIy2OgxD.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-D1nHfvlC.js"></script>
+    <script type="module" crossorigin src="/assets/index-DIy2OgxD.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DMk3iz3Q.css">
   </head>
   <body>

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