Sfoglia il codice sorgente

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 mesi fa
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 — 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 — 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.
 - **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).
 - **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
 ### 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.
 - **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.
 - **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.
 - **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:
         if not archive:
             return False
             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():
         if archive.file_path and archive.file_path.strip():
             file_path = settings.base_dir / archive.file_path
             file_path = settings.base_dir / archive.file_path
             if file_path.exists():
             if file_path.exists():
@@ -1041,13 +1042,11 @@ class ArchiveService:
                         f"SECURITY: Refusing to delete archive {archive_id} - "
                         f"SECURITY: Refusing to delete archive {archive_id} - "
                         f"path {archive_dir} is outside archive directory {settings.archive_dir}"
                         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.delete(archive)
                     await self.db.commit()
                     await self.db.commit()
                     return True
                     return True
 
 
                 # Safety check 2: archive_dir must be at least 1 level deep inside archive_dir
                 # 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:
                 try:
                     relative_path = archive_dir.resolve().relative_to(settings.archive_dir.resolve())
                     relative_path = archive_dir.resolve().relative_to(settings.archive_dir.resolve())
                     if len(relative_path.parts) < 1:
                     if len(relative_path.parts) < 1:
@@ -1061,16 +1060,22 @@ class ArchiveService:
                 except ValueError:
                 except ValueError:
                     pass  # Already handled above
                     pass  # Already handled above
 
 
-                shutil.rmtree(archive_dir, ignore_errors=True)
+                dir_to_delete = archive_dir
         else:
         else:
             logger.error(
             logger.error(
                 f"SECURITY: Refusing to delete files for archive {archive_id} - "
                 f"SECURITY: Refusing to delete files for archive {archive_id} - "
                 f"file_path is empty or invalid: '{archive.file_path}'"
                 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.delete(archive)
         await self.db.commit()
         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
         return True
 
 
     async def attach_timelapse(
     async def attach_timelapse(

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

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

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

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

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

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

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

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

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

@@ -1236,14 +1236,34 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   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 printing = 0;
     let idle = 0;
     let idle = 0;
     let offline = 0;
     let offline = 0;
     let loading = 0;
     let loading = 0;
+    let nextPrinterName: string | null = null;
+    let nextRemainingMin: number | null = null;
+    let nextProgress: number = 0;
 
 
     printers?.forEach((printer) => {
     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) {
       if (status === undefined) {
         // Status not yet loaded - don't count as offline yet
         // Status not yet loaded - don't count as offline yet
         loading++;
         loading++;
@@ -1251,35 +1271,35 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
         offline++;
         offline++;
       } else if (status.state === 'RUNNING') {
       } else if (status.state === 'RUNNING') {
         printing++;
         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 {
       } else {
         idle++;
         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;
   if (!printers?.length) return null;
 
 
   return (
   return (
     <div className="flex items-center gap-4 text-sm">
     <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 && (
       {counts.printing > 0 && (
         <div className="flex items-center gap-1.5">
         <div className="flex items-center gap-1.5">
           <div className="w-2 h-2 rounded-full bg-bambu-green animate-pulse" />
           <div className="w-2 h-2 rounded-full bg-bambu-green animate-pulse" />
@@ -1288,14 +1308,6 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
           </span>
           </span>
         </div>
         </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 && (
       {counts.offline > 0 && (
         <div className="flex items-center gap-1.5">
         <div className="flex items-center gap-1.5">
           <div className="w-2 h-2 rounded-full bg-gray-400" />
           <div className="w-2 h-2 rounded-full bg-gray-400" />
@@ -1304,6 +1316,23 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
           </span>
           </span>
         </div>
         </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>
     </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 -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <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">
     <link rel="stylesheet" crossorigin href="/assets/index-DMk3iz3Q.css">
   </head>
   </head>
   <body>
   <body>

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