Procházet zdrojové kódy

fix(ui): one toast and one close when saving an edited queue item; a failed Unassign on the printer card shows its error; the OBS overlay applies live status updates; bulk upload shows each same-named file its own result; queue timeline and history rows show the queued plate's thumbnail; remove the Inventory "Full" column that nothing fills

maziggy před 2 dny
rodič
revize
51610b9f19

+ 6 - 0
CHANGELOG.md

@@ -164,6 +164,12 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Saving an edited queue item showed "Queue item updated" twice** — The dialog also closed twice, a failed save showed two error toasts, and an edit that also added the job for further printers closed the dialog before those were added. It now reports and closes once, after everything is saved.
+- **A failed Unassign on the printer card said nothing** — Unassigning a spool from an AMS slot (built-in inventory or Spoolman) that the server refused showed no message, and the spool simply stayed assigned. The error is now shown.
+- **The OBS stream overlay ignored live updates** — It read the printer's status updates from the wrong field, so it moved only with its 2-second refresh instead of in real time.
+- **Uploading two files with the same name could show the wrong result per file** — When both were uploaded together, both rows showed the first file's result, so one that failed looked uploaded. Each row now shows its own file's result.
+- **The queue timeline and history rows showed plate 1 for every plate** — A queued plate of a multi-plate file appeared with the file's first plate as its picture, while the queue list showed the right plate. All three now show the queued plate.
+- **Inventory: removed the "Full" column** — It showed whether a spool had been added full, which nothing ever recorded, so every row read "-".
 - **Spoolman: the print dialog's cost estimate uses the Spoolman spool's price** — In Spoolman mode, the print dialog estimated each slot from the built-in inventory's assignments, which hold nothing in that mode or rows left from before the switch to Spoolman. The estimate (and the budget check that uses it) came out at the default price per kg or at a spool no longer in the slot, and the "g left" shown for each slot was wrong the same way. Both now come from the spool bound to the slot, priced the way the print is charged: the spool's own price, else its filament's, else the default.
 - **Spoolman: a print whose 3MF arrived late is charged from the 3MF** — A print that started before Bambuddy could fetch its 3MF (an "Untitled" print, a printer briefly unreachable) gets an empty archive that is filled in once the 3MF turns up. In Spoolman mode its filament was still charged from the AMS remaining-percentage change — in steps of 10 g on a 1 kg spool, nothing for a spool without a percentage, and nothing at all when no slot reported one at the start. The recovered 3MF's usage is now used, as the built-in inventory already did.
 - **Spoolman inventory: Cost/kg shows the price prints are charged** — A Spoolman spool priced only through its filament (the spool itself has no price, or a price of 0) showed "-" or "0.00" in the Inventory's Cost/kg column and no price on the SpoolBuddy spool card, although every print off it was charged that filament price. Both now show it, the column marked "(filament price)"; the spool's own price still wins, and editing the spool does not copy the filament's price onto it.

+ 8 - 5
backend/app/api/routes/archives.py

@@ -4484,12 +4484,14 @@ async def upload_archives_bulk(
     printer_scope.ensure(printer_id)
     from backend.app.api.routes.library import validate_print_file_upload
 
+    # Each outcome carries its file's position in the request: two files of
+    # the same name cannot be told apart by name.
     results = []
     errors = []
 
-    for file in files:
+    for index, file in enumerate(files):
         if not file.filename or not file.filename.endswith(".3mf"):
-            errors.append({"filename": file.filename or "unknown", "error": "Not a .3mf file"})
+            errors.append({"index": index, "filename": file.filename or "unknown", "error": "Not a .3mf file"})
             continue
 
         safe_filename = _safe_filename(file.filename)
@@ -4507,7 +4509,7 @@ async def upload_archives_bulk(
             try:
                 validate_print_file_upload(file.filename, content)
             except HTTPException as exc:
-                errors.append({"filename": file.filename, "error": exc.detail})
+                errors.append({"index": index, "filename": file.filename, "error": exc.detail})
                 continue
             temp_path.write_bytes(content)
 
@@ -4522,16 +4524,17 @@ async def upload_archives_bulk(
             if archive:
                 results.append(
                     {
+                        "index": index,
                         "filename": file.filename,
                         "id": archive.id,
                         "status": "success",
                     }
                 )
             else:
-                errors.append({"filename": file.filename, "error": "Failed to process"})
+                errors.append({"index": index, "filename": file.filename, "error": "Failed to process"})
         except Exception as e:
             logger.exception("Failed to upload archive %s: %s", file.filename, e)
-            errors.append({"filename": file.filename, "error": "Failed to process file"})
+            errors.append({"index": index, "filename": file.filename, "error": "Failed to process file"})
         finally:
             if temp_path.exists():
                 temp_path.unlink()

+ 29 - 0
backend/tests/integration/test_archives_api.py

@@ -110,6 +110,35 @@ class TestArchivesAPI:
         for call in archive_print_mock.await_args_list:
             assert call.kwargs.get("prefer_filename_for_name") is prefer_filename_for_name
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_archives_bulk_numbers_each_outcome_by_its_file(
+        self, async_client: AsyncClient, archive_factory, printer_factory
+    ):
+        """Two files of the same name in one batch, the second failing: the
+        upload dialog matched outcomes to its rows by name, so both rows took
+        the first one's success. Each outcome now says which file it was."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, print_name="Benchy")
+        archive_print_mock = AsyncMock(side_effect=[archive, None])
+
+        files = [
+            ("files", ("Benchy.3mf", b"PK\x03\x04fake3mf", "application/octet-stream")),
+            ("files", ("Benchy.3mf", b"PK\x03\x04fake3mf", "application/octet-stream")),
+            ("files", ("notes.txt", b"hello", "text/plain")),
+        ]
+
+        with patch(
+            "backend.app.api.routes.archives.ArchiveService.archive_print",
+            archive_print_mock,
+        ):
+            response = await async_client.post("/api/v1/archives/upload-bulk", files=files)
+
+        assert response.status_code == 200
+        body = response.json()
+        assert [(r["index"], r["id"]) for r in body["results"]] == [(0, archive.id)]
+        assert [e["index"] for e in body["errors"]] == [1, 2]
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_upload_archives_bulk_defaults_prefer_filename_for_name_false(

+ 93 - 0
frontend/src/__tests__/components/PrintModalEditSave.test.tsx

@@ -0,0 +1,93 @@
+/**
+ * Saving an edited queue item reports and closes once.
+ *
+ * The update mutation toasted, called onSuccess and closed the dialog on its
+ * own, and the submit handler then did all three again: two "Queue item
+ * updated" toasts, two closes, and on a failure two error toasts.
+ */
+
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+import { render } from '../utils';
+import { server } from '../mocks/server';
+
+const mockShowToast = vi.fn();
+
+vi.mock('../../contexts/ToastContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
+  return { ...actual, useToast: () => ({ showToast: mockShowToast }) };
+});
+
+import { PrintModal } from '../../components/PrintModal';
+
+const queueItem = {
+  id: 9, printer_id: 1, archive_id: 1, position: 1, scheduled_time: null, require_previous_success: false,
+  auto_off_after: false, gcode_injection: false, manual_start: false, ams_mapping: null, plate_id: null,
+  bed_levelling: true, flow_cali: false, vibration_cali: true, layer_inspect: false, timelapse: false,
+  use_ams: true, status: 'pending', started_at: null, completed_at: null, error_message: null,
+  created_at: '2024-01-01T00:00:00Z', archive_name: 'Edit Print', archive_thumbnail: null,
+  printer_name: 'X1 Carbon', print_time_seconds: 3600, batch_id: null, batch_name: null,
+};
+
+function renderModal(onClose = vi.fn(), onSuccess = vi.fn()) {
+  render(
+    <PrintModal
+      mode="edit-queue-item"
+      archiveId={1}
+      archiveName="Edit Print"
+      queueItem={queueItem as never}
+      onClose={onClose}
+      onSuccess={onSuccess}
+    />,
+  );
+  return { onClose, onSuccess };
+}
+
+describe('PrintModal edit-queue-item save', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    server.use(
+      http.get('/api/v1/printers/', () =>
+        HttpResponse.json([{ id: 1, name: 'X1 Carbon', model: 'X1C', ip_address: '192.168.1.100', enabled: true, is_active: true }]),
+      ),
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] })),
+      http.get('/api/v1/archives/:id', () => HttpResponse.json({ id: 1, sliced_for_model: null })),
+      http.get('/api/v1/archives/:id/plates', () => HttpResponse.json({ is_multi_plate: false, plates: [] })),
+      http.get('/api/v1/archives/:id/filament-requirements', () => HttpResponse.json({ filaments: [] })),
+    );
+  });
+
+  it('toasts, reports success and closes once', async () => {
+    server.use(http.patch('/api/v1/queue/:id', () => HttpResponse.json({ id: 9, status: 'pending' })));
+    const user = userEvent.setup();
+    const { onClose, onSuccess } = renderModal();
+
+    await user.click(await screen.findByRole('button', { name: /save/i }));
+
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+    // Let anything still queued behind the first close run before counting.
+    await new Promise((r) => setTimeout(r, 50));
+    expect(mockShowToast).toHaveBeenCalledTimes(1);
+    expect(onSuccess).toHaveBeenCalledTimes(1);
+    expect(onClose).toHaveBeenCalledTimes(1);
+  });
+
+  it('shows one error toast on a failure and stays open', async () => {
+    server.use(
+      http.patch('/api/v1/queue/:id', () => HttpResponse.json({ detail: 'Queue item is printing' }, { status: 409 })),
+    );
+    const user = userEvent.setup();
+    const { onClose } = renderModal();
+
+    await user.click(await screen.findByRole('button', { name: /save/i }));
+
+    await waitFor(() => expect(mockShowToast).toHaveBeenCalled());
+    await new Promise((r) => setTimeout(r, 50));
+    expect(mockShowToast).toHaveBeenCalledTimes(1);
+    expect(mockShowToast.mock.calls[0][1]).toBe('error');
+    expect(onClose).not.toHaveBeenCalled();
+  });
+});

+ 92 - 0
frontend/src/__tests__/components/QueueItemPlateThumbnail.test.tsx

@@ -0,0 +1,92 @@
+/**
+ * A queued plate shows that plate's picture everywhere it is listed.
+ *
+ * The queue list asked for the plate thumbnail, but the timeline and the
+ * compact history row asked for the file's, which is plate 1: two plates of
+ * one file queued side by side looked like the same job.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { render } from '@testing-library/react';
+import { QueueTimelineView } from '../../components/QueueTimelineView';
+import { CompactHistoryRow } from '../../components/CompactHistoryRow';
+import type { PrintQueueItem, Printer } from '../../api/client';
+
+const printers = [{ id: 1, name: 'Workshop X1C', model: 'X1C' }] as unknown as Printer[];
+
+const item = (over: Partial<PrintQueueItem>) =>
+  ({
+    id: 1,
+    printer_id: 1,
+    status: 'printing',
+    position: 0,
+    archive_name: 'Two plates',
+    print_time_seconds: 3600,
+    started_at: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
+    scheduled_time: null,
+    manual_start: false,
+    waiting_reason: null,
+    plate_id: 4,
+    ...over,
+  }) as unknown as PrintQueueItem;
+
+const fromArchive = { archive_id: 7, archive_thumbnail: 'thumb.png' };
+const fromLibrary = { archive_id: null, archive_thumbnail: null, library_file_id: 9, library_file_thumbnail: 'thumb.png' };
+
+function timelineImages(queued: PrintQueueItem): string[] {
+  const { container, unmount } = render(
+    <QueueTimelineView
+      queueItems={[queued]}
+      printers={printers}
+      printerStatuses={{ 1: { progress: 50, remaining_time: 30, state: 'RUNNING' } }}
+      sjfEnabled={false}
+      onItemClick={() => {}}
+      t={(key: string) => key}
+    />,
+  );
+  const srcs = Array.from(container.querySelectorAll('img')).map((img) => img.getAttribute('src') ?? '');
+  unmount();
+  return srcs;
+}
+
+function historyImages(done: PrintQueueItem): string[] {
+  const { container, unmount } = render(
+    <CompactHistoryRow
+      item={done}
+      onRequeue={() => {}}
+      onRemove={() => {}}
+      hasPermission={() => true}
+      canModify={() => true}
+      t={(key: string) => key}
+    />,
+  );
+  const srcs = Array.from(container.querySelectorAll('img')).map((img) => img.getAttribute('src') ?? '');
+  unmount();
+  return srcs;
+}
+
+describe('queued plate thumbnail', () => {
+  it.each([
+    ['an archive', fromArchive, '/archives/7/plate-thumbnail/4'],
+    ['a library file', fromLibrary, '/library/files/9/plate-thumbnail/4'],
+  ])('the timeline shows the plate of %s', (_, source, url) => {
+    const srcs = timelineImages(item(source));
+    expect(srcs.length).toBeGreaterThan(0);
+    for (const src of srcs) expect(src).toContain(url);
+  });
+
+  it.each([
+    ['an archive', fromArchive, '/archives/7/plate-thumbnail/4'],
+    ['a library file', fromLibrary, '/library/files/9/plate-thumbnail/4'],
+  ])('a history row shows the plate of %s', (_, source, url) => {
+    const srcs = historyImages(item({ ...source, status: 'completed', completed_at: new Date().toISOString() }));
+    expect(srcs.length).toBeGreaterThan(0);
+    for (const src of srcs) expect(src).toContain(url);
+  });
+
+  it('a single-plate job keeps the file thumbnail', () => {
+    for (const src of timelineImages(item({ ...fromArchive, plate_id: null }))) {
+      expect(src).toContain('/archives/7/thumbnail');
+    }
+  });
+});

+ 52 - 1
frontend/src/__tests__/components/UploadModal.test.tsx

@@ -20,7 +20,7 @@ describe('UploadModal', () => {
         return HttpResponse.json({
           uploaded: 1,
           failed: 0,
-          results: [{ id: 1, filename: 'test.3mf' }],
+          results: [{ index: 0, id: 1, filename: 'test.3mf', status: 'success' }],
           errors: [],
         });
       })
@@ -155,6 +155,57 @@ describe('UploadModal', () => {
       });
     });
 
+    // SN-C10: rows took their outcome by file name, so two files of the same
+    // name both showed the first one's success and the failure was lost.
+    it('shows each file its own outcome when two share a name', async () => {
+      server.use(
+        http.post('/api/v1/archives/upload-bulk', () =>
+          HttpResponse.json({
+            uploaded: 1,
+            failed: 1,
+            results: [{ index: 0, id: 1, filename: 'Benchy.3mf', status: 'success' }],
+            errors: [{ index: 1, filename: 'Benchy.3mf', error: 'Not a valid 3MF' }],
+          }),
+        ),
+      );
+      const initialFiles = [
+        new File(['one'], 'Benchy.3mf', { type: 'application/3mf' }),
+        new File(['two'], 'Benchy.3mf', { type: 'application/3mf' }),
+      ];
+      render(<UploadModal onClose={mockOnClose} initialFiles={initialFiles} />);
+
+      fireEvent.click(screen.getByRole('button', { name: /Upload/i }));
+
+      expect(await screen.findByText('Not a valid 3MF')).toBeInTheDocument();
+      expect(document.querySelectorAll('.text-bambu-green.w-4')).toHaveLength(1);
+    });
+
+    it('shows each failed file its own error when two share a name', async () => {
+      server.use(
+        http.post('/api/v1/archives/upload-bulk', () =>
+          HttpResponse.json({
+            uploaded: 0,
+            failed: 2,
+            results: [],
+            errors: [
+              { index: 0, filename: 'Benchy.3mf', error: 'First failed' },
+              { index: 1, filename: 'Benchy.3mf', error: 'Second failed' },
+            ],
+          }),
+        ),
+      );
+      const initialFiles = [
+        new File(['one'], 'Benchy.3mf', { type: 'application/3mf' }),
+        new File(['two'], 'Benchy.3mf', { type: 'application/3mf' }),
+      ];
+      render(<UploadModal onClose={mockOnClose} initialFiles={initialFiles} />);
+
+      fireEvent.click(screen.getByRole('button', { name: /Upload/i }));
+
+      expect(await screen.findByText('First failed')).toBeInTheDocument();
+      expect(screen.getByText('Second failed')).toBeInTheDocument();
+    });
+
     it('Upload button is disabled when no files are pending', async () => {
       render(<UploadModal onClose={mockOnClose} initialFiles={[]} />);
 

+ 48 - 0
frontend/src/__tests__/pages/InventoryPageFullColumn.test.tsx

@@ -0,0 +1,48 @@
+/**
+ * The Inventory table has no "Full" column.
+ *
+ * It showed `added_full`, which nothing has ever written, so every row read
+ * "-". A column layout saved while it was switched on must not bring it back.
+ */
+
+import { describe, it, expect, afterEach, vi } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import InventoryPageRouter from '../../pages/InventoryPage';
+
+const SPOOL = {
+  id: 5, material: 'PETG', subtype: null, brand: 'eSun', color_name: 'Blue', rgba: '0000FFFF',
+  extra_colors: null, effect_type: null, label_weight: 1000, core_weight: 250, core_weight_catalog_id: null,
+  weight_used: 400, slicer_filament: null, slicer_filament_name: null, nozzle_temp_min: null,
+  nozzle_temp_max: null, note: null, added_full: null, last_used: null, encode_time: null, tag_uid: null,
+  tray_uuid: null, data_origin: null, tag_type: null, archived_at: null, created_at: '2025-01-01T00:00:00Z',
+  updated_at: '2025-01-01T00:00:00Z', k_profiles: [], cost_per_kg: null, last_scale_weight: null,
+  last_weighed_at: null, storage_location: null, category: null, low_stock_threshold_pct: null,
+  spoolman_id: null, spoolman_filament_id: null,
+};
+
+afterEach(() => {
+  vi.mocked(localStorage.getItem).mockReset();
+});
+
+describe('InventoryPage — Full column', () => {
+  it('is not shown, even from a saved layout that had it on', async () => {
+    const saved = JSON.stringify([
+      { id: 'material', label: 'Material', visible: true },
+      { id: 'added_full', label: 'Full', visible: true },
+    ]);
+    vi.mocked(localStorage.getItem).mockImplementation((key: string) =>
+      key === 'bambuddy-inventory-columns' ? saved : null,
+    );
+    server.use(http.get('/api/v1/inventory/spools', () => HttpResponse.json([SPOOL])));
+
+    render(<InventoryPageRouter />);
+
+    await waitFor(() => expect(screen.getAllByText('PETG').length).toBeGreaterThan(0));
+    const headers = screen.getAllByRole('columnheader').map((h) => h.textContent?.trim());
+    expect(headers).toContain('Material');
+    expect(headers).not.toContain('Full');
+  });
+});

+ 80 - 1
frontend/src/__tests__/pages/PrintersPage.test.tsx

@@ -3,7 +3,7 @@
  */
 
 import { describe, it, expect, beforeEach } from 'vitest';
-import { screen, waitFor, fireEvent, within } from '@testing-library/react';
+import { act, screen, waitFor, fireEvent, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { PrintersPage } from '../../pages/PrintersPage';
@@ -1743,3 +1743,82 @@ describe('PrintersPage Phase 14 — Local-Branch BL-detection symmetry', () => {
     expect(definedUnassign.length).toBe(0);
   });
 });
+
+/**
+ * F-U17: a failed Unassign on the printer card was silent in both inventory
+ * modes -- no toast, and the spool stayed assigned, so it looked like the
+ * click did nothing.
+ */
+describe('PrintersPage — Unassign failure', () => {
+  const genericTray = {
+    id: 0,
+    tray_type: 'PLA',
+    tray_uuid: '00000000000000000000000000000000',
+    tag_uid: '0000000000000000',
+    tray_color: 'FF0000FF',
+    tray_sub_brands: 'Generic PLA',
+  };
+
+  beforeEach(() => {
+    phase14HoverCardProps.length = 0;
+    localStorage.removeItem('printerCardSize');
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json(mockPrinters)),
+      http.get('/api/v1/printers/:id/status', () =>
+        HttpResponse.json({ ...mockPrinterStatus, ams: [{ id: 0, tray: [genericTray] }] }),
+      ),
+      http.get('/api/v1/queue/', () => HttpResponse.json([])),
+    );
+  });
+
+  async function unassignFromCard() {
+    const withUnassign = await waitFor(() => {
+      const found = phase14HoverCardProps.find(
+        (p) => typeof (p.inventory as { onUnassignSpool?: () => void } | undefined)?.onUnassignSpool === 'function',
+      );
+      expect(found).toBeDefined();
+      return found!;
+    }, { timeout: 3000 });
+    act(() => {
+      (withUnassign.inventory as { onUnassignSpool: () => void }).onUnassignSpool();
+    });
+  }
+
+  it('reports a failed built-in unassign', async () => {
+    server.use(
+      http.get('/api/v1/spoolman/status', () => HttpResponse.json({ enabled: false, connected: false })),
+      http.get('/api/v1/inventory/assignments', () => HttpResponse.json([{
+        id: 1, spool_id: 42, printer_id: 1, ams_id: 0, tray_id: 0, printer_name: 'X1 Carbon', ams_label: null,
+        spool: { id: 42, material: 'PLA', brand: 'Generic', color_name: 'Red', label_weight: 1000, weight_used: 0, rgba: 'FF0000FF' },
+      }])),
+      http.delete('/api/v1/inventory/assignments/:pid/:aid/:tid', () =>
+        HttpResponse.json({ detail: 'Built-in unassign refused' }, { status: 500 }),
+      ),
+    );
+    render(<PrintersPage />);
+
+    await unassignFromCard();
+
+    expect(await screen.findByText(/Built-in unassign refused/)).toBeInTheDocument();
+  });
+
+  it('reports a failed Spoolman unassign', async () => {
+    server.use(
+      http.get('/api/v1/spoolman/status', () => HttpResponse.json({ enabled: true, connected: true })),
+      http.get('/api/v1/spoolman/inventory/spools', () => HttpResponse.json([
+        { id: 77, material: 'PLA', brand: 'Generic', color_name: 'Red', label_weight: 1000, weight_used: 0, rgba: 'FF0000FF' },
+      ])),
+      http.get('/api/v1/spoolman/inventory/slot-assignments/all', () => HttpResponse.json([
+        { printer_id: 1, printer_name: 'X1 Carbon', ams_id: 0, tray_id: 0, spoolman_spool_id: 77 },
+      ])),
+      http.delete('/api/v1/spoolman/inventory/slot-assignments/:id', () =>
+        HttpResponse.json({ detail: 'Spoolman unassign refused' }, { status: 503 }),
+      ),
+    );
+    render(<PrintersPage />);
+
+    await unassignFromCard();
+
+    expect(await screen.findByText(/Spoolman unassign refused/)).toBeInTheDocument();
+  });
+});

+ 29 - 0
frontend/src/__tests__/pages/StreamOverlayPage.test.tsx

@@ -486,6 +486,35 @@ describe('StreamOverlayPage', () => {
     });
   });
 
+  describe('live updates', () => {
+    // ws-1: the backend sends a status update as `data`; the overlay read
+    // `data.status`, which is undefined, so every websocket update was
+    // dropped and only the 2 s poll moved it.
+    it('applies a websocket status update, keeping fields it does not carry', async () => {
+      const sockets: Array<{ onmessage: ((e: { data: string }) => void) | null }> = [];
+      vi.stubGlobal(
+        'WebSocket',
+        vi.fn().mockImplementation(function (this: { close: () => void; onmessage: null; onerror: null }) {
+          this.close = vi.fn();
+          this.onmessage = null;
+          this.onerror = null;
+          sockets.push(this);
+        }),
+      );
+      server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockStatusPrinting)));
+      renderOverlayPage(1);
+      expect(await screen.findByText('45%')).toBeInTheDocument();
+      await waitFor(() => expect(sockets[0]?.onmessage).toBeTypeOf('function'));
+
+      act(() => {
+        sockets[0].onmessage!({ data: JSON.stringify({ type: 'printer_status', printer_id: 1, data: { progress: 46 } }) });
+      });
+
+      expect(await screen.findByText('46%')).toBeInTheDocument();
+      expect(screen.getByText('Benchy')).toBeInTheDocument();
+    });
+  });
+
   describe('invalid printer', () => {
     it('shows invalid printer message for ID 0', async () => {
       renderOverlayPage(0);

+ 3 - 2
frontend/src/api/client.ts

@@ -1021,8 +1021,9 @@ export interface FailureAnalysis {
 export interface BulkUploadResult {
   uploaded: number;
   failed: number;
-  results: Array<{ filename: string; id: number; status: string }>;
-  errors: Array<{ filename: string; error: string }>;
+  /** `index` is the file's position in the request. */
+  results: Array<{ index: number; filename: string; id: number; status: string }>;
+  errors: Array<{ index: number; filename: string; error: string }>;
 }
 
 // Archive Comparison types

+ 2 - 6
frontend/src/components/CompactHistoryRow.tsx

@@ -12,7 +12,7 @@ import {
   AlertCircle,
   Weight,
 } from 'lucide-react';
-import { api } from '../api/client';
+import { queueItemThumbnailUrl } from '../utils/queueItemThumbnail';
 import { type TimeFormat, formatDuration, formatRelativeTime } from '../utils/date';
 import type { PrintQueueItem, Permission } from '../api/client';
 import { Button } from './Button';
@@ -57,11 +57,7 @@ export function CompactHistoryRow({
   const StatusIcon = config.icon;
   const displayName = queueItemDisplayName(item);
 
-  const thumbnailUrl = item.archive_thumbnail
-    ? api.getArchiveThumbnail(item.archive_id!)
-    : item.library_file_thumbnail
-      ? api.getLibraryFileThumbnailUrl(item.library_file_id!)
-      : null;
+  const thumbnailUrl = queueItemThumbnailUrl(item);
 
   const completedTime = item.completed_at || item.created_at;
   const filamentColor = normalizeFilamentColor(item.filament_color);

+ 3 - 10
frontend/src/components/PrintModal/index.tsx

@@ -921,18 +921,11 @@ export function PrintModal({
     mutationFn: (data: PrintQueueItemCreate) => api.addToQueue(data),
   });
 
-  // Update queue item mutation
+  // Update queue item mutation. The submit handler reports the outcome and
+  // closes, once for the whole save: an edit can add entries for further
+  // printers after this runs.
   const updateQueueMutation = useMutation({
     mutationFn: (data: PrintQueueItemUpdate) => api.updateQueueItem(queueItem!.id, data),
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['queue'] });
-      showToast('Queue item updated');
-      onSuccess?.();
-      onClose();
-    },
-    onError: (error: Error) => {
-      showToast(error.message || 'Failed to update queue item', 'error');
-    },
   });
 
   // Get mapping for a specific printer (per-printer override or default).

+ 2 - 6
frontend/src/components/QueueTimelineView.tsx

@@ -4,7 +4,7 @@ import { formatDuration, parseUTCDate } from '../utils/date';
 import { compareQueueOrder, queueLaneKey } from '../utils/queueOrder';
 import { isBusyOnlyWaitingReason } from '../utils/waitingReason';
 import type { PrintQueueItem, Printer } from '../api/client';
-import { api } from '../api/client';
+import { queueItemThumbnailUrl } from '../utils/queueItemThumbnail';
 import { Button } from './Button';
 
 /** Gantt-style 24h-rolling timeline. One horizontal swimlane per printer
@@ -401,11 +401,7 @@ export function QueueTimelineView({
                       const displayName = ev.item.archive_name
                         || ev.item.library_file_name
                         || `#${ev.item.id}`;
-                      const thumbnailUrl = ev.item.archive_thumbnail
-                        ? api.getArchiveThumbnail(ev.item.archive_id!)
-                        : ev.item.library_file_thumbnail
-                          ? api.getLibraryFileThumbnailUrl(ev.item.library_file_id!)
-                          : null;
+                      const thumbnailUrl = queueItemThumbnailUrl(ev.item);
                       const isPrinting = ev.type === 'printing';
                       const isBatched = ev.item.batch_id != null;
                       const tooltipParts = [

+ 5 - 4
frontend/src/components/UploadModal.tsx

@@ -43,16 +43,17 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
   const uploadMutation = useMutation({
     mutationFn: (filesToUpload: File[]) =>
       api.uploadArchivesBulk(filesToUpload),
-    onSuccess: (result) => {
+    onSuccess: (result, sent) => {
       setUploadResult(result);
       queryClient.invalidateQueries({ queryKey: ['archives'] });
       queryClient.invalidateQueries({ queryKey: ['archiveStats'] });
 
-      // Update file statuses based on result
+      // Update file statuses based on result. Each outcome names its file by
+      // position in the batch sent; two files can share a name.
       setFiles((prev) =>
         prev.map((f) => {
-          const success = result.results.find((r) => r.filename === f.file.name);
-          const error = result.errors.find((e) => e.filename === f.file.name);
+          const success = result.results.find((r) => sent[r.index] === f.file);
+          const error = result.errors.find((e) => sent[e.index] === f.file);
           if (success) {
             return { ...f, status: 'success', archiveId: success.id };
           }

+ 0 - 5
frontend/src/pages/InventoryPage.tsx

@@ -113,7 +113,6 @@ const DEFAULT_COLUMNS: ColumnConfig[] = [
   { id: 'label_weight', label: 'Label', visible: true },
   { id: 'net', label: 'Net', visible: true },
   { id: 'gross', label: 'Gross', visible: false },
-  { id: 'added_full', label: 'Full', visible: false },
   { id: 'used', label: 'Used', visible: false },
   { id: 'printed_total', label: 'Printed Total', visible: false },
   { id: 'printed_since_weight', label: 'Printed Since Weight', visible: false },
@@ -254,7 +253,6 @@ const columnHeaders: Record<string, (t: TFn) => string> = {
   label_weight: (t) => t('inventory.labelWeight'),
   net: (t) => t('inventory.net'),
   gross: () => 'Gross',
-  added_full: () => 'Full',
   used: (t) => t('inventory.weightUsed'),
   printed_total: () => 'Printed Total',
   printed_since_weight: () => 'Printed Since Weight',
@@ -396,9 +394,6 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
   gross: ({ spool, remaining }) => (
     <span className="text-sm text-bambu-gray">{formatWeight(remaining + spool.core_weight)}</span>
   ),
-  added_full: ({ spool }) => (
-    <span className="text-sm text-bambu-gray">{spool.added_full == null ? '-' : spool.added_full ? 'Yes' : 'No'}</span>
-  ),
   used: ({ spool }) => (
     <span className="text-sm text-bambu-gray">{spool.weight_used > 0 ? formatWeight(spool.weight_used) : '-'}</span>
   ),

+ 4 - 0
frontend/src/pages/PrintersPage.tsx

@@ -9129,6 +9129,9 @@ export function PrintersPage() {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
     },
+    // Called fire-and-forget from the card: without this a failure is silent
+    // and the spool simply stays assigned.
+    onError: (error: Error) => showToast(error.message || t('spoolman.unlinkFailed'), 'error'),
   });
 
   const { data: spoolmanSpools, isLoading: spoolmanSpoolsLoading } = useQuery({
@@ -9151,6 +9154,7 @@ export function PrintersPage() {
       queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
       queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
     },
+    onError: (error: Error) => showToast(error.message || t('spoolman.unlinkFailed'), 'error'),
   });
 
   // Helper to find assignment for a specific slot

+ 4 - 20
frontend/src/pages/QueuePage.tsx

@@ -21,6 +21,7 @@ import {
 } from '@dnd-kit/sortable';
 import { CSS } from '@dnd-kit/utilities';
 import { queueItemDisplayName } from '../utils/queueItemName';
+import { queueItemThumbnailUrl } from '../utils/queueItemThumbnail';
 import {
   Clock,
   Trash2,
@@ -704,6 +705,7 @@ function SortableQueueItem({
       : null;
 
   const isMobileSelectable = isPending && onToggleSelect;
+  const thumbnailUrl = queueItemThumbnailUrl(item);
 
   return (
     <div
@@ -803,26 +805,8 @@ function SortableQueueItem({
 
         {/* Thumbnail - use plate-specific thumbnail if plate_id is set */}
         <div className="w-10 h-10 sm:w-14 sm:h-14 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
-          {item.archive_thumbnail ? (
-            <img
-              src={
-                item.plate_id != null
-                  ? api.getArchivePlateThumbnail(item.archive_id!, item.plate_id)
-                  : api.getArchiveThumbnail(item.archive_id!)
-              }
-              alt=""
-              className="w-full h-full object-cover"
-            />
-          ) : item.library_file_thumbnail ? (
-            <img
-              src={
-                item.plate_id != null
-                  ? api.getLibraryFilePlateThumbnail(item.library_file_id!, item.plate_id)
-                  : api.getLibraryFileThumbnailUrl(item.library_file_id!)
-              }
-              alt=""
-              className="w-full h-full object-cover"
-            />
+          {thumbnailUrl ? (
+            <img src={thumbnailUrl} alt="" className="w-full h-full object-cover" />
           ) : (
             <div className="w-full h-full flex items-center justify-center text-bambu-gray">
               <Layers className="w-5 h-5 sm:w-6 sm:h-6" />

+ 5 - 1
frontend/src/pages/StreamOverlayPage.tsx

@@ -296,7 +296,11 @@ export function StreamOverlayPage() {
       try {
         const data = JSON.parse(event.data);
         if (data.type === 'printer_status' && data.printer_id === id) {
-          queryClient.setQueryData(['printerStatus', id], data.status);
+          // The update is the status itself, under `data`; merge it, as the
+          // app's own socket does, in case it carries only some fields.
+          queryClient.setQueryData(['printerStatus', id], (old: Record<string, unknown> | undefined) =>
+            old ? { ...old, ...data.data } : data.data,
+          );
         }
       } catch {
         // Ignore parse errors

+ 28 - 0
frontend/src/utils/queueItemThumbnail.ts

@@ -0,0 +1,28 @@
+import { api } from '../api/client';
+
+/**
+ * Thumbnail for a queue item, wherever one is shown: the queued plate's when
+ * the item names one, else the file's (plate 1). The timeline and history rows
+ * used the file's for every plate, so two plates of one file looked alike.
+ */
+interface ThumbnailQueueItem {
+  archive_id?: number | null;
+  archive_thumbnail?: string | null;
+  library_file_id?: number | null;
+  library_file_thumbnail?: string | null;
+  plate_id?: number | null;
+}
+
+export function queueItemThumbnailUrl(item: ThumbnailQueueItem): string | null {
+  if (item.archive_thumbnail && item.archive_id != null) {
+    return item.plate_id != null
+      ? api.getArchivePlateThumbnail(item.archive_id, item.plate_id)
+      : api.getArchiveThumbnail(item.archive_id);
+  }
+  if (item.library_file_thumbnail && item.library_file_id != null) {
+    return item.plate_id != null
+      ? api.getLibraryFilePlateThumbnail(item.library_file_id, item.plate_id)
+      : api.getLibraryFileThumbnailUrl(item.library_file_id);
+  }
+  return null;
+}