Преглед на файлове

Merge perf/farm-fanout: batched printer-card reads (/api/v1/bulk) and server-paged queue history

Brought in line with dev: bulk card plugs shaped per caller (REST URLs only for plug editors), bulk archive folders keep to the caller's printers, queue delete and Clear History share dev's end time and grouping refresh, Clear History with an API key keeps to its owner's read-own limit, type fixes, Queue page wiring tests on the paged history, watchdog duration test tolerant of a slow run.
maziggy преди 1 ден
родител
ревизия
9f2d15f70f
променени са 100 файла, в които са добавени 2557 реда и са изтрити 173 реда
  1. 8 0
      CHANGELOG.md
  2. 273 0
      backend/app/api/routes/bulk.py
  3. 4 0
      backend/app/api/routes/firmware.py
  4. 25 21
      backend/app/api/routes/ha_sensors.py
  5. 17 6
      backend/app/api/routes/library.py
  6. 313 44
      backend/app/api/routes/print_queue.py
  7. 14 1
      backend/app/api/routes/printers.py
  8. 14 1
      backend/app/api/routes/smart_plugs.py
  9. 2 0
      backend/app/main.py
  10. 20 0
      backend/app/schemas/print_queue.py
  11. 29 0
      backend/app/utils/id_list.py
  12. 21 0
      backend/tests/integration/test_api_key_own_rows.py
  13. 445 0
      backend/tests/integration/test_bulk_reads_api.py
  14. 476 0
      backend/tests/integration/test_queue_history_api.py
  15. 5 1
      backend/tests/unit/test_connection_watchdog.py
  16. 28 0
      backend/tests/unit/test_id_list.py
  17. 198 0
      frontend/src/__tests__/api/batch.test.ts
  18. 4 1
      frontend/src/__tests__/components/PrintModal.test.tsx
  19. 52 0
      frontend/src/__tests__/mocks/handlers.ts
  20. 90 0
      frontend/src/__tests__/pages/PrintersPageBatchedReads.test.tsx
  21. 3 0
      frontend/src/__tests__/pages/PrintersPageCameraSplitButton.test.tsx
  22. 155 13
      frontend/src/__tests__/pages/QueuePage.test.tsx
  23. 20 9
      frontend/src/__tests__/pages/QueuePageWiring.test.tsx
  24. 9 1
      frontend/src/__tests__/setup.ts
  25. 133 0
      frontend/src/api/batch.ts
  26. 122 14
      frontend/src/api/client.ts
  27. 3 1
      frontend/src/components/PrinterQueueWidget.tsx
  28. 2 2
      frontend/src/pages/PrintersPage.tsx
  29. 65 50
      frontend/src/pages/QueuePage.tsx
  30. 0 0
      static/assets/AdditionalSection-DdPAuWaE.js
  31. 0 0
      static/assets/AmsUnitCard-BGvo_F3b.js
  32. 0 0
      static/assets/ArchivesPage-DN-L26xj.js
  33. 0 0
      static/assets/AreaChart-DURfIplq.js
  34. 0 0
      static/assets/AssignToAmsModal-Cq6w0JgX.js
  35. 0 0
      static/assets/AuthContext-DM3YxV_q.js
  36. 1 1
      static/assets/Button-C_-d0X5W.js
  37. 0 0
      static/assets/CamWallPage-Dc7zAekP.js
  38. 0 0
      static/assets/CameraDiagnoseModal-BydfnYfD.js
  39. 0 0
      static/assets/CameraPage-DXL0JpHU.js
  40. 0 0
      static/assets/CameraWall-DBrA6v1h.js
  41. 1 1
      static/assets/Card-BT_YWvIC.js
  42. 0 0
      static/assets/CartesianChart-DAoqaVbj.js
  43. 0 0
      static/assets/ColumnConfigModal-DHbBcF1q.js
  44. 0 0
      static/assets/ConfigureAmsSlotModal-qbxE23MO.js
  45. 0 0
      static/assets/ConfirmModal-lZYjzFL6.js
  46. 0 0
      static/assets/ConfirmOutcomeDialog-B-f6oDOv.js
  47. 0 0
      static/assets/ConnectAuthorizePage-RJGJ6Ng3.js
  48. 0 0
      static/assets/ConnectionDiagnostic-jD6ObGRm.js
  49. 0 0
      static/assets/ContextMenu-Ch1EMtUK.js
  50. 0 0
      static/assets/CopyButton-BPTbakjY.js
  51. 0 0
      static/assets/ErrorBarContext-EgF5FiX5.js
  52. 0 0
      static/assets/ExternalLinkPage-euN1onZW.js
  53. 1 1
      static/assets/FilamentSwatch-CElzyuMD.js
  54. 0 1
      static/assets/FileManagerPage-CN1v606g.js
  55. 0 0
      static/assets/FileUploadModal-Bj1b8vZP.js
  56. 0 0
      static/assets/FinancePage-Ckiak_8E.js
  57. 0 0
      static/assets/GCodeViewerPage-Dymps3eX.js
  58. 0 0
      static/assets/GcodeToolpathViewer-D7Hem9M3.js
  59. 0 0
      static/assets/GroupEditPage-DLjnxysJ.js
  60. 0 0
      static/assets/IconPicker-Bdnx5WSm.js
  61. 0 0
      static/assets/ImagePreviewModal-CxVgU3EM.js
  62. 0 0
      static/assets/InventoryPage-BZGzMItL.js
  63. 0 0
      static/assets/Layout-BE8fdF9U.js
  64. 0 0
      static/assets/LibraryTrashPage-DmFmO6Zj.js
  65. 0 0
      static/assets/LoginPage-DqpAbx6i.js
  66. 0 0
      static/assets/MaintenancePage-BKNjosyr.js
  67. 0 0
      static/assets/ModelSourcesPage-BNqWYg2M.js
  68. 0 0
      static/assets/NotificationsPage-BH7yCb55.js
  69. 1 1
      static/assets/NumberInput-BhsGuNcX.js
  70. 1 1
      static/assets/OverlayFrame-CPrBopi9.js
  71. 0 0
      static/assets/PdfPreviewModal-CgIODxir.js
  72. 0 0
      static/assets/PhotoGalleryModal-Dy3RV0a3.js
  73. 0 0
      static/assets/PrintModal-DJVIzPGw.js
  74. 0 0
      static/assets/PrinterLocationsPage-D0SKHcm8.js
  75. 0 0
      static/assets/PrintersPage-CB4FC-Ph.js
  76. 0 0
      static/assets/ProfilesPage-C7nFKumA.js
  77. 0 0
      static/assets/ProjectDetailPage-DyI40PbB.js
  78. 0 0
      static/assets/ProjectsPage-BuB2O1SW.js
  79. 0 0
      static/assets/QueryClientProvider-B5vEWYBq.js
  80. 0 0
      static/assets/QueryClientProvider-CfzZA5MJ.js
  81. 0 0
      static/assets/QueuePage-D_6Y-y7F.js
  82. 0 0
      static/assets/QueuePage-Xyqf5-O-.js
  83. 0 0
      static/assets/ReferenceLine-CYMTRrT9.js
  84. 0 0
      static/assets/RichTextEditor-COtD6BDe.js
  85. 0 0
      static/assets/SettingsPage-CvhvM_iZ.js
  86. 0 0
      static/assets/SetupPage-M3LKrrCB.js
  87. 0 0
      static/assets/SliceModal-B-118oXv.js
  88. 0 0
      static/assets/SpoolBuddyAmsPage-C3v9i05Y.js
  89. 0 0
      static/assets/SpoolBuddyDashboard-CJnfrGIo.js
  90. 0 0
      static/assets/SpoolBuddyInventoryPage-CNu9Lx3S.js
  91. 1 1
      static/assets/SpoolBuddySettingsPage-B80Zf1Ne.js
  92. 0 0
      static/assets/SpoolBuddyWriteTagPage-B6PhrrO_.js
  93. 1 1
      static/assets/SpoolIcon-Ctt9uuSs.js
  94. 0 0
      static/assets/SpreadsheetPreviewModal-DOiHrhWo.js
  95. 0 0
      static/assets/StatsPage-BfkKD_sb.js
  96. 0 0
      static/assets/StreamOverlayPage-BPYmo-Mp.js
  97. 0 0
      static/assets/SystemHealthPanel-DKhr_xng.js
  98. 0 0
      static/assets/SystemInfoPage-CnRoIemM.js
  99. 0 0
      static/assets/ThemeContext-DGVIadPK.js
  100. 0 0
      static/assets/ToastContext-C3zxW_t3.js

+ 8 - 0
CHANGELOG.md

@@ -168,6 +168,14 @@ 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
+- **The Printers page sent hundreds of requests on a print farm, and every page asked for each printer's status** — Each printer card fetched its own status, slot presets, AMS labels, power plug, action buttons, sensor readings, firmware check and queue, one request per printer. With 59 printers that was about 550 requests when the page opened, which the server worked through one behind the other, so cards took 5 to 20 seconds to fill. The sidebar, the camera wall and other pages also fetched every printer's status one by one.
+  - **Now batched:** requests for the same kind of data that arrive within a few milliseconds go out as one request, so the Printers page sends about one request per kind of data however many printers there are. The cards' own refresh timers are unchanged, including the 30-second status poll, and they too are combined into one request each round.
+  - **Same answers:** each combined request returns exactly what the per-printer one did, with the same permissions and printer access. Refreshing a single printer after an action, or a page that shows one printer, still sends the per-printer request.
+  - **Archives page:** the folders shown on each archive card are fetched the same way, instead of one request per card that each loaded the whole folder list.
+- **Opening the Queue page could stall Bambuddy for over a minute on a print farm** — The page fetched the whole queue, finished runs included, every five seconds and sorted out the history in the browser. With a few thousand finished runs that was several megabytes per refresh, and building the answer held up every other request: printer cards, the camera, everything, until a proxy in front of Bambuddy gave up with a timeout.
+  - **History is paged on the server:** the History tab loads 50 runs at a time and **Show more** fetches the next 50. Sorting by date, name or printer and the printer, status and location filters are applied by the server. A batch row still counts all of its runs.
+  - **The queue itself** refreshes only pending, printing and skipped jobs every five seconds.
+  - **Clear History is one request** instead of one per finished run, with the same rule as before: a run a batch order still needs is kept as cancelled (#2960). It now clears exactly what the History tab shows, so the location filter counts too, as the confirmation's number already did.
 - **Saving in the timelapse editor could fail on long videos, and ignored the music volume** — Save re-encoded the video while the browser waited, which for a long timelapse took minutes. A browser or reverse proxy that gave up showed an error while FFmpeg went on running on the server.
   - **Runs in the background:** Save now starts the work on the server and returns at once. The button shows how far it has got, **Cancel** stops FFmpeg and keeps the original, and an editor closed and opened again shows the progress. While one edit of a timelapse runs, Save is unavailable for it. If the timelapse is removed or replaced while the edit runs, the edit is not saved over it.
   - **Music volume:** the volume slider only changed the preview; the saved video always had the music at full volume. It is now mixed in at the volume set, and muted means silent.

+ 273 - 0
backend/app/api/routes/bulk.py

@@ -0,0 +1,273 @@
+"""Bulk reads for pages that show many printers (or archives) at once.
+
+The Printers page asked for each printer's status, slot presets, AMS labels,
+plugs, sensor readings, firmware and queue with one request per printer, and
+the Archives page for each card's folders with one per archive. On a print
+farm that was hundreds of requests per page load, queued one behind the other
+on the server. The frontend (api/batch.ts) coalesces those per-id calls into
+these.
+
+Each route answers exactly what its single-id route answers, through the same
+helper. A printer outside the caller's scope, or one that doesn't exist, is
+left out; the client then asks the single route for it, which gives its 404.
+
+They live under their own prefix so no ``/{id}`` route of another router can
+take them for an id.
+"""
+
+from fastapi import APIRouter, Depends, Query
+from sqlalchemy import and_, func, or_, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.api.routes.firmware import FirmwareUpdateInfo, _checks_enabled, _not_checked, _printer_update_info
+from backend.app.api.routes.ha_sensors import _card_sensors, _reading
+from backend.app.api.routes.library import _folders_by_archive
+from backend.app.api.routes.print_queue import _QUEUE_ITEM_LOADS, _enrich_response
+from backend.app.api.routes.printers import _ams_labels, _printer_status, _slot_presets_by_key
+from backend.app.api.routes.smart_plugs import (
+    CallerEditsPlugs,
+    PrinterCardPlugs,
+    _card_script_plugs,
+    _pick_main_plug,
+    _serialize_plug,
+)
+from backend.app.core.auth import (
+    ApiKeyActor,
+    RequestPrinterScope,
+    RequirePermissionIfAuthEnabled,
+    require_ownership_permission,
+)
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.models.printer_ha_sensor import PrinterHASensor
+from backend.app.models.slot_preset import SlotPresetMapping
+from backend.app.models.smart_plug import SmartPlug
+from backend.app.models.user import User
+from backend.app.schemas.library import FolderResponse
+from backend.app.schemas.print_queue import PrintQueueItemResponse
+from backend.app.schemas.printer import PrinterStatus
+from backend.app.schemas.printer_ha_sensor import PrinterHASensorReading
+from backend.app.schemas.smart_plug import SmartPlugResponse
+from backend.app.services.firmware_check import get_firmware_service
+from backend.app.utils.id_list import parse_id_list
+
+router = APIRouter(prefix="/bulk", tags=["bulk"])
+
+
+@router.get("/printer-statuses", response_model=list[PrinterStatus])
+async def get_printer_statuses(
+    ids: str = Query(..., description="Comma-separated printer ids"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    db: AsyncSession = Depends(get_db),
+):
+    """``GET /printers/{printer_id}/status`` for several printers. Unknown ids are left out."""
+    wanted = [i for i in parse_id_list(ids) if printer_scope.allows(i)]
+    if not wanted:
+        return []
+    result = await db.execute(select(Printer).where(Printer.id.in_(wanted)))
+    printers = {p.id: p for p in result.scalars().all()}
+    return [await _printer_status(db, printers[i]) for i in wanted if i in printers]
+
+
+@router.get("/slot-presets")
+async def get_slot_presets_bulk(
+    ids: str = Query(..., description="Comma-separated printer ids"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    db: AsyncSession = Depends(get_db),
+):
+    """``GET /printers/{printer_id}/slot-presets`` for several printers, keyed by printer id."""
+    wanted = [i for i in parse_id_list(ids) if printer_scope.allows(i)]
+    by_printer: dict[int, list] = {i: [] for i in wanted}
+    if wanted:
+        result = await db.execute(select(SlotPresetMapping).where(SlotPresetMapping.printer_id.in_(wanted)))
+        for mapping in result.scalars().all():
+            by_printer[mapping.printer_id].append(mapping)
+    return {i: _slot_presets_by_key(mappings) for i, mappings in by_printer.items()}
+
+
+@router.get("/ams-labels")
+async def get_ams_labels_bulk(
+    ids: str = Query(..., description="Comma-separated printer ids"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    db: AsyncSession = Depends(get_db),
+):
+    """``GET /printers/{printer_id}/ams-labels`` for several printers, keyed by printer id."""
+    wanted = [i for i in parse_id_list(ids) if printer_scope.allows(i)]
+    return {i: await _ams_labels(db, i) for i in wanted}
+
+
+@router.get("/card-plugs", response_model=None, responses={200: {"model": dict[int, PrinterCardPlugs]}})
+async def get_smart_plugs_by_printers(
+    ids: str = Query(..., description="Comma-separated printer ids"),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    can_edit: bool = CallerEditsPlugs,
+):
+    """``GET /smart-plugs/by-printer/{printer_id}`` and its ``/scripts``, for several printers.
+
+    The Printers page asked both once per printer card. A printer outside the
+    caller's scope is left out, which the client reports as the single routes'
+    404.
+    """
+    wanted = [i for i in parse_id_list(ids) if printer_scope.allows(i)]
+    by_printer: dict[int, list[SmartPlug]] = {i: [] for i in wanted}
+    if wanted:
+        result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id.in_(wanted)).order_by(SmartPlug.id))
+        for plug in result.scalars().all():
+            if plug.printer_id is not None:
+                by_printer[plug.printer_id].append(plug)
+    # Shaped as the single routes shape them: the main plug in the editor's
+    # form for a caller who may edit plugs (see _serialize_plug), so no
+    # response_model here either.
+    cards = {}
+    for i, plugs in by_printer.items():
+        plug = _pick_main_plug(plugs)
+        cards[i] = {
+            "plug": _serialize_plug(plug, can_edit=can_edit) if plug else None,
+            "scripts": [SmartPlugResponse.model_validate(p) for p in _card_script_plugs(plugs)],
+        }
+    return cards
+
+
+@router.get("/ha-sensor-readings", response_model=dict[int, list[PrinterHASensorReading]])
+async def get_sensor_readings_by_printers(
+    ids: str = Query(..., description="Comma-separated printer ids"),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
+):
+    """``GET /ha-sensors/by-printer/{printer_id}/readings`` for several printers, keyed by printer id.
+
+    The Printers page asked once per card every 15 seconds. A printer outside
+    the caller's scope is left out, which the client reports as the single
+    route's 404.
+    """
+    wanted = [i for i in parse_id_list(ids) if printer_scope.allows(i)]
+    readings: dict[int, list[PrinterHASensorReading]] = {i: [] for i in wanted}
+    if wanted:
+        for sensor in await _card_sensors(db, PrinterHASensor.printer_id.in_(wanted)):
+            readings[sensor.printer_id].append(_reading(sensor))
+    return readings
+
+
+@router.get("/firmware-updates", response_model=list[FirmwareUpdateInfo])
+async def check_printers_firmware(
+    ids: str = Query(..., description="Comma-separated printer ids"),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
+):
+    """``GET /firmware/updates/{printer_id}`` for several printers.
+
+    The Printers page asked once per card. Unknown printers, and printers
+    outside the caller's scope, are left out, which the client reports as the
+    single route's 404.
+    """
+    wanted = [i for i in parse_id_list(ids) if printer_scope.allows(i)]
+    if not wanted:
+        return []
+    result = await db.execute(select(Printer).where(Printer.id.in_(wanted)))
+    printers = {p.id: p for p in result.scalars().all()}
+    ordered = [printers[i] for i in wanted if i in printers]
+    if not await _checks_enabled(db):
+        return [_not_checked(p) for p in ordered]
+    firmware_service = get_firmware_service()
+    return [await _printer_update_info(firmware_service, p) for p in ordered]
+
+
+@router.get("/printer-queues", response_model=dict[int, list[PrintQueueItemResponse]])
+async def list_queue_by_printers(
+    ids: str = Query(..., description="Comma-separated printer ids"),
+    status: str | None = Query(None, description="Filter by status; several may be given comma-separated"),
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.QUEUE_READ_ALL,
+            Permission.QUEUE_READ_OWN,
+        )
+    ),
+    printer_scope: PrinterScope = RequestPrinterScope,
+):
+    """``GET /queue/?printer_id=N&status=...`` for several printers, keyed by printer id.
+
+    Every printer card asked for its own queue, twice (the card and its queue
+    widget). Each list is what the single call returns: the printer's own jobs
+    plus the "Any <model>" jobs for its model, in dispatch order. A job for a
+    model shows up under every printer of that model.
+    """
+    user, can_read_all = auth_result
+    wanted = parse_id_list(ids)
+    if not wanted:
+        return {}
+    models = {
+        printer_id: model.lower()
+        for printer_id, model in (
+            await db.execute(select(Printer.id, Printer.model).where(Printer.id.in_(wanted)))
+        ).all()
+        if model
+    }
+
+    query = (
+        select(PrintQueueItem)
+        .options(*_QUEUE_ITEM_LOADS)
+        .where(
+            or_(
+                PrintQueueItem.printer_id.in_(wanted),
+                and_(
+                    PrintQueueItem.printer_id.is_(None),
+                    func.lower(PrintQueueItem.target_model).in_(set(models.values())),
+                ),
+            )
+        )
+        # The order the scheduler dispatches in (#3200), as GET /queue/ returns it
+        .order_by(PrintQueueItem.position, PrintQueueItem.id)
+    )
+    if user is not None and not can_read_all:
+        query = query.where(PrintQueueItem.created_by_id == user.id)
+    # Items bound to printers the caller can't see stay out (#1727)
+    if (clause := printer_scope.where(PrintQueueItem.printer_id)) is not None:
+        query = query.where(clause)
+    if status:
+        query = query.where(PrintQueueItem.status.in_([s.strip() for s in status.split(",") if s.strip()]))
+
+    items = (await db.execute(query)).scalars().all()
+    responses = {item.id: _enrich_response(item) for item in items}
+
+    def belongs(item: PrintQueueItem, printer_id: int) -> bool:
+        if item.printer_id is not None:
+            return item.printer_id == printer_id
+        model = models.get(printer_id)
+        return bool(model and item.target_model and item.target_model.lower() == model)
+
+    return {printer_id: [responses[item.id] for item in items if belongs(item, printer_id)] for printer_id in wanted}
+
+
+@router.get("/archive-folders", response_model=dict[int, list[FolderResponse]])
+async def get_folders_by_archives(
+    ids: str = Query(..., description="Comma-separated archive ids"),
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
+    printer_scope: PrinterScope = RequestPrinterScope,
+):
+    """``GET /library/folders/by-archive/{archive_id}`` for several archives, keyed by archive id.
+
+    The Archives page asked once per archive card, each time loading the whole
+    folder index to work out what the user may see.
+    """
+    user, _ = auth_result
+    archive_ids = parse_id_list(ids)
+    if not archive_ids:
+        return {}
+    return await _folders_by_archive(db, user, archive_ids, printer_scope)

+ 4 - 0
backend/app/api/routes/firmware.py

@@ -185,7 +185,11 @@ async def check_printer_firmware(
 
     if not await _checks_enabled(db):
         return _not_checked(printer)
+    return await _printer_update_info(firmware_service, printer)
 
+
+async def _printer_update_info(firmware_service, printer: Printer) -> FirmwareUpdateInfo:
+    """Whether *printer* has a firmware update, as ``GET /updates/{printer_id}`` reports it."""
     current_version = _current_version(printer.id)
 
     # Check for update

+ 25 - 21
backend/app/api/routes/ha_sensors.py

@@ -113,35 +113,39 @@ async def get_printer_sensor_readings(
     to its last persisted state, marked unreachable, rather than vanishing
     from the card on every restart.
     """
+    return [_reading(sensor) for sensor in await _card_sensors(db, PrinterHASensor.printer_id == printer_id)]
+
+
+async def _card_sensors(db: AsyncSession, printer_clause) -> list[PrinterHASensor]:
+    """The card-visible sensors matching *printer_clause*, in card order."""
     result = await db.execute(
         select(PrinterHASensor)
         .where(
-            PrinterHASensor.printer_id == printer_id,
+            printer_clause,
             PrinterHASensor.show_on_printer_card.is_(True),
         )
         .order_by(PrinterHASensor.sort_order, PrinterHASensor.id)
     )
+    return list(result.scalars().all())
 
-    readings = []
-    for sensor in result.scalars().all():
-        cached = ha_sensor_manager.get_reading(sensor.id)
-        readings.append(
-            PrinterHASensorReading(
-                id=sensor.id,
-                name=sensor.name,
-                entity_id=sensor.entity_id,
-                kind=sensor.kind,
-                device_class=sensor.device_class,
-                unit=sensor.unit,
-                state=cached.state if cached else sensor.last_state,
-                value=cached.value if cached else None,
-                alerting=cached.alerting if cached else False,
-                block_print=sensor.block_print,
-                reachable=cached.reachable if cached else False,
-                last_changed=sensor.last_changed,
-            )
-        )
-    return readings
+
+def _reading(sensor: PrinterHASensor) -> PrinterHASensorReading:
+    """A sensor's live reading from the poller's cache, else its last persisted state."""
+    cached = ha_sensor_manager.get_reading(sensor.id)
+    return PrinterHASensorReading(
+        id=sensor.id,
+        name=sensor.name,
+        entity_id=sensor.entity_id,
+        kind=sensor.kind,
+        device_class=sensor.device_class,
+        unit=sensor.unit,
+        state=cached.state if cached else sensor.last_state,
+        value=cached.value if cached else None,
+        alerting=cached.alerting if cached else False,
+        block_print=sensor.block_print,
+        reachable=cached.reachable if cached else False,
+        last_changed=sensor.last_changed,
+    )
 
 
 @router.post("/", response_model=PrinterHASensorResponse)

+ 17 - 6
backend/app/api/routes/library.py

@@ -1220,18 +1220,28 @@ async def get_folders_by_archive(
 ):
     """Get all folders linked to a specific archive, those the user may see (#3201)."""
     user, _ = auth_result
-    archives = await _linkable_archives(db, [archive_id], printer_scope)
-    if archive_id not in archives:
-        return []
+    return (await _folders_by_archive(db, user, [archive_id], printer_scope))[archive_id]
+
+
+async def _folders_by_archive(
+    db: AsyncSession, user: User | ApiKeyActor | None, archive_ids: list[int], printer_scope: PrinterScope
+) -> dict[int, list]:
+    """The folders linked to each archive that *user* may see (#3201), by archive id.
+
+    An archive on a printer outside *printer_scope* has none.
+    """
+    folders: dict[int, list] = {archive_id: [] for archive_id in archive_ids}
+    archives = await _linkable_archives(db, archive_ids, printer_scope)
+    if not archives:
+        return folders
     index = await _load_index(db, user)
     visible = visible_folder_ids(index, user)
     own_files = _own_files_filter(user)
     result = await db.execute(
-        select(LibraryFolder).where(LibraryFolder.archive_id == archive_id).order_by(LibraryFolder.name)
+        select(LibraryFolder).where(LibraryFolder.archive_id.in_(list(archives))).order_by(LibraryFolder.name)
     )
     rows = [folder for folder in result.scalars().all() if visible is None or folder.id in visible]
 
-    folders = []
     for folder in rows:
         # Get file count + latest file activity (#1770/#2680) in one trip. Prefer
         # the real on-disk mtime (external scans), fall back to the DB updated_at.
@@ -1250,7 +1260,8 @@ async def get_folders_by_archive(
         own_activity = folder.fs_modified_at or folder.updated_at
         latest_activity_at = max(own_activity, latest_file) if latest_file is not None else own_activity
 
-        folders.append(
+        assert folder.archive_id is not None  # selected by archive_id
+        folders[folder.archive_id].append(
             FolderResponse(
                 id=folder.id,
                 name=folder.name,

+ 313 - 44
backend/app/api/routes/print_queue.py

@@ -6,13 +6,14 @@ import re
 import zipfile
 from datetime import datetime, timezone
 from pathlib import Path
+from typing import Any, Literal
 
 import defusedxml.ElementTree as ET
 from fastapi import APIRouter, Depends, HTTPException, Query
-from sqlalchemy import and_, func, inspect, or_, select, update
+from sqlalchemy import ColumnElement, UnaryExpression, and_, case, func, inspect, or_, select, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy.orm import selectinload
+from sqlalchemy.orm import aliased, selectinload
 
 from backend.app.api.routes.library_variants import normalize_model_name, resolve_variant_model
 from backend.app.core.auth import (
@@ -47,6 +48,8 @@ from backend.app.schemas.print_queue import (
     PrintBatchUpdate,
     PrintQueueBulkUpdate,
     PrintQueueBulkUpdateResponse,
+    PrintQueueHistoryClearResponse,
+    PrintQueueHistoryResponse,
     PrintQueueItemCreate,
     PrintQueueItemResponse,
     PrintQueueItemUpdate,
@@ -676,10 +679,22 @@ async def _active_printer_model(db: AsyncSession, model: str) -> str | None:
     ).scalar_one_or_none()
 
 
+# What _enrich_response reads, loaded up front for a list of items.
+_QUEUE_ITEM_LOADS = (
+    selectinload(PrintQueueItem.archive),
+    selectinload(PrintQueueItem.printer),
+    selectinload(PrintQueueItem.library_file),
+    selectinload(PrintQueueItem.created_by),
+    selectinload(PrintQueueItem.batch),
+    # Cross-model candidates (#671) and their files, for the card label.
+    selectinload(PrintQueueItem.variants).selectinload(PrintQueueVariant.library_file),
+)
+
+
 @router.get("/", response_model=list[PrintQueueItemResponse])
 async def list_queue(
     printer_id: int | None = Query(None, description="Filter by printer (-1 for unassigned)"),
-    status: str | None = Query(None, description="Filter by status"),
+    status: str | None = Query(None, description="Filter by status; several may be given comma-separated"),
     target_model: str | None = Query(
         None, description="Filter by target model (also includes model-based items when combined with printer_id)"
     ),
@@ -696,15 +711,7 @@ async def list_queue(
     user, can_read_all = auth_result
     query = (
         select(PrintQueueItem)
-        .options(
-            selectinload(PrintQueueItem.archive),
-            selectinload(PrintQueueItem.printer),
-            selectinload(PrintQueueItem.library_file),
-            selectinload(PrintQueueItem.created_by),
-            selectinload(PrintQueueItem.batch),
-            # Cross-model candidates (#671) and their files, for the card label.
-            selectinload(PrintQueueItem.variants).selectinload(PrintQueueVariant.library_file),
-        )
+        .options(*_QUEUE_ITEM_LOADS)
         # The order the scheduler dispatches in (#3200), so the first pending
         # item for a printer is the one it will start next -- which is what the
         # printer card's "Next in queue" shows. Sorting by printer first put
@@ -758,6 +765,244 @@ async def list_queue(
     return [_enrich_response(item) for item in items]
 
 
+HISTORY_STATUSES = ("completed", "failed", "skipped", "cancelled")
+
+
+def _history_conditions(
+    user: User | ApiKeyActor | None,
+    can_read_all: bool,
+    printer_scope: PrinterScope,
+    printer_id: int | None,
+    status: str | None,
+    location: str | None,
+) -> list:
+    """WHERE clauses for the history the caller sees under the page's filters.
+
+    Mirrors what the Queue page used to filter in the browser: the printer and
+    status selectors, and the location selector, which matches a model-based
+    run by its target location and any other run by its printer's location.
+    """
+    statuses = [status] if status in HISTORY_STATUSES else list(HISTORY_STATUSES) if not status else []
+    conditions: list[ColumnElement[bool]] = [PrintQueueItem.status.in_(statuses)]
+    if user is not None and not can_read_all:
+        conditions.append(PrintQueueItem.created_by_id == user.id)
+    # Items bound to printers the caller can't see stay out (#1727)
+    if (clause := printer_scope.where(PrintQueueItem.printer_id)) is not None:
+        conditions.append(clause)
+    if printer_id is not None:
+        if printer_id == -1:
+            conditions.append(PrintQueueItem.printer_id.is_(None))
+        else:
+            conditions.append(PrintQueueItem.printer_id == printer_id)
+    if location:
+        # Aliased so it can't correlate with a Printer joined by the caller
+        located = aliased(Printer)
+        no_target_location = or_(PrintQueueItem.target_location.is_(None), PrintQueueItem.target_location == "")
+        conditions.append(
+            or_(
+                PrintQueueItem.target_location == location,
+                and_(
+                    no_target_location,
+                    PrintQueueItem.printer_id.in_(select(located.id).where(located.location == location)),
+                ),
+            )
+        )
+    return conditions
+
+
+@router.get("/history", response_model=PrintQueueHistoryResponse)
+async def list_queue_history(
+    printer_id: int | None = Query(None, description="Filter by printer (-1 for unassigned)"),
+    status: str | None = Query(None, description="One of completed, failed, skipped, cancelled"),
+    location: str | None = Query(None, description="Filter by location"),
+    sort_by: Literal["date", "name", "printer"] = Query("date"),
+    reverse: bool = Query(False, description="Flip the default order (newest first, A to Z)"),
+    # No practical cap: "Show more" can grow the page to the whole history,
+    # as the page always could.
+    limit: int = Query(50, ge=1, le=1_000_000),
+    offset: int = Query(0, ge=0),
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.QUEUE_READ_ALL,
+            Permission.QUEUE_READ_OWN,
+        )
+    ),
+    printer_scope: PrinterScope = RequestPrinterScope,
+):
+    """Finished, failed, skipped and cancelled runs, one page at a time.
+
+    The Queue page used to load the whole queue, history included, every five
+    seconds and page through it in the browser. On a farm that is thousands of
+    rows, each enriched from its 3MF, and the request held up every other one
+    for over a minute.
+    """
+    user, can_read_all = auth_result
+    conditions = _history_conditions(user, can_read_all, printer_scope, printer_id, status, location)
+
+    total = (await db.execute(select(func.count(PrintQueueItem.id)).where(*conditions))).scalar_one()
+
+    order: list[UnaryExpression[Any]]
+    if sort_by == "name":
+        # The name the row shows: a live archive's print name or file name,
+        # else the library file's (its metadata print name is not sortable in
+        # SQL, so the file name stands in for it).
+        archive_name = case(
+            (
+                PrintArchive.deleted_at.is_(None),
+                func.coalesce(func.nullif(PrintArchive.print_name, ""), PrintArchive.filename),
+            ),
+            else_=None,
+        )
+        name = func.lower(func.coalesce(func.nullif(archive_name, ""), LibraryFile.filename, ""))
+        order = [name.desc() if reverse else name.asc()]
+    elif sort_by == "printer":
+        name = func.lower(func.coalesce(Printer.name, ""))
+        order = [name.desc() if reverse else name.asc()]
+    else:
+        when = func.coalesce(PrintQueueItem.completed_at, PrintQueueItem.created_at)
+        order = [when.asc() if reverse else when.desc()]
+    order.append(PrintQueueItem.id.asc() if reverse else PrintQueueItem.id.desc())
+
+    page_ids = list(
+        (
+            await db.execute(
+                select(PrintQueueItem.id)
+                .outerjoin(PrintArchive, PrintArchive.id == PrintQueueItem.archive_id)
+                .outerjoin(LibraryFile, LibraryFile.id == PrintQueueItem.library_file_id)
+                .outerjoin(Printer, Printer.id == PrintQueueItem.printer_id)
+                .where(*conditions)
+                .order_by(*order)
+                .limit(limit)
+                .offset(offset)
+            )
+        ).scalars()
+    )
+
+    # The other runs of the batches on this page, so a batch row counts them all
+    batch_ids = (
+        (
+            await db.execute(
+                select(PrintQueueItem.batch_id)
+                .where(PrintQueueItem.id.in_(page_ids), PrintQueueItem.batch_id.isnot(None))
+                .distinct()
+            )
+        )
+        .scalars()
+        .all()
+        if page_ids
+        else []
+    )
+    sibling_ids: list[int] = []
+    if batch_ids:
+        sibling_ids = list(
+            (
+                await db.execute(
+                    select(PrintQueueItem.id)
+                    .where(*conditions, PrintQueueItem.batch_id.in_(batch_ids), PrintQueueItem.id.notin_(page_ids))
+                    .order_by(PrintQueueItem.id)
+                )
+            ).scalars()
+        )
+
+    ids = page_ids + sibling_ids
+    rows = {}
+    if ids:
+        result = await db.execute(select(PrintQueueItem).options(*_QUEUE_ITEM_LOADS).where(PrintQueueItem.id.in_(ids)))
+        rows = {item.id: item for item in result.scalars().all()}
+
+    locations = (
+        (
+            await db.execute(
+                select(PrintQueueItem.target_location)
+                .where(
+                    *_history_conditions(user, can_read_all, printer_scope, None, None, None),
+                    PrintQueueItem.target_location.isnot(None),
+                    PrintQueueItem.target_location != "",
+                )
+                .distinct()
+            )
+        )
+        .scalars()
+        .all()
+    )
+
+    return {
+        "items": [_enrich_response(rows[i]) for i in ids if i in rows],
+        "total": total,
+        "locations": sorted(location for location in locations if location),
+    }
+
+
+@router.post("/history/clear", response_model=PrintQueueHistoryClearResponse)
+async def clear_queue_history(
+    printer_id: int | None = Query(None, description="Filter by printer (-1 for unassigned)"),
+    status: str | None = Query(None, description="One of completed, failed, skipped, cancelled"),
+    location: str | None = Query(None, description="Filter by location"),
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.QUEUE_DELETE_ALL,
+            Permission.QUEUE_DELETE_OWN,
+        )
+    ),
+    read_result: tuple[User | ApiKeyActor | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.QUEUE_READ_ALL,
+            Permission.QUEUE_READ_OWN,
+        )
+    ),
+    printer_scope: PrinterScope = RequestPrinterScope,
+):
+    """Remove the history the Queue page shows under its current filters.
+
+    The page used to send one DELETE per history row. Each row goes through
+    the same rule as that DELETE: an order's last run for a plate is kept as
+    cancelled rather than deleted (#2960). Rows are handled one at a time, in
+    one transaction, so each check sees the rows removed before it.
+
+    Reaches only rows the caller could both see and delete, as the page's
+    one-by-one DELETEs did: someone who may delete every run but read only
+    their own clears only their own.
+    """
+    user, can_modify_all = auth_result
+    read_user, can_read_all = read_result
+    # An API key's owner comes from the check that limits it: a key whose
+    # owner may delete every run but read only their own gets no actor from
+    # the delete check, and would otherwise clear everyone's history.
+    user = user if user is not None else read_user
+    conditions = _history_conditions(user, can_modify_all and can_read_all, printer_scope, printer_id, status, location)
+    # The order the page listed them in when it deleted them one by one, which
+    # decides which of an order's runs is the last one left to keep.
+    items = (
+        (
+            await db.execute(
+                select(PrintQueueItem).where(*conditions).order_by(PrintQueueItem.position, PrintQueueItem.id)
+            )
+        )
+        .scalars()
+        .all()
+    )
+
+    cleared = kept = 0
+    item_ids = [item.id for item in items]
+    deleted_batch_ids: list[int | None] = []
+    for item in items:
+        batch_id = item.batch_id
+        if await _remove_queue_item(db, item):
+            cleared += 1
+            deleted_batch_ids.append(batch_id)
+        else:
+            kept += 1
+        await db.flush()
+    await db.commit()
+    await _refresh_batches_after_delete(db, deleted_batch_ids)
+    _notify_dispatch_cancelled(item_ids)
+
+    logger.info("Cleared queue history: %s deleted, %s kept as cancelled for their order", cleared, kept)
+    return {"cleared": cleared, "kept": kept}
+
+
 async def _resolve_queue_variants(
     db: AsyncSession,
     specs: list[QueueVariantCreate],
@@ -2321,6 +2566,57 @@ async def update_queue_item(
     return _enrich_response(item)
 
 
+async def _remove_queue_item(db: AsyncSession, item: PrintQueueItem) -> bool:
+    """Delete *item*, or cancel it when its order still needs it (#2960).
+
+    Returns True when the row was deleted. The caller commits. A completed run
+    is always deleted: it is the record of something that was actually made,
+    and rewriting it as cancelled would falsify the order's progress.
+    """
+    item_id = item.id
+    keep_as_cancelled = item.status != "completed" and await _is_orders_last_source(db, item)
+
+    await release_budget_reservation(
+        db,
+        source_type="print_queue",
+        source_id=item_id,
+        status="released",
+    )
+    if keep_as_cancelled:
+        item.status = "cancelled"
+        item.completed_at = datetime.now(timezone.utc)
+        await db.flush()
+        # The order may have been sitting on "completed" if this run's target
+        # was met by it; cancelling reopens it.
+        await refresh_batch_status_for_item(db, item_id)
+    else:
+        await db.delete(item)
+    return not keep_as_cancelled
+
+
+async def _refresh_batches_after_delete(db: AsyncSession, batch_ids: list[int | None]) -> None:
+    """A grouping whose last open job was deleted may be finished now. After the commit."""
+    for batch_id in dict.fromkeys(i for i in batch_ids if i is not None):
+        try:
+            batch = await db.get(PrintBatch, batch_id)
+            if batch is not None and await refresh_batch_status(db, batch):
+                await db.commit()
+        except Exception as e:
+            logger.warning("Failed to refresh batch %s after a delete: %s", batch_id, e)
+
+
+def _notify_dispatch_cancelled(item_ids: list[int]) -> None:
+    """Stop an in-flight preheat for these items, once their change is committed.
+
+    The dispatch coroutine is parked in a sleep and cannot see the status we
+    just wrote (#2727); woken before the commit, it would read the old one.
+    """
+    from backend.app.services.print_scheduler import scheduler as _scheduler
+
+    for item_id in item_ids:
+        _scheduler.notify_dispatch_cancelled(item_id)
+
+
 @router.delete("/{item_id}")
 async def delete_queue_item(
     item_id: int,
@@ -2357,39 +2653,12 @@ async def delete_queue_item(
     if item.status == "printing":
         raise HTTPException(400, "Cannot delete item that is currently printing")
 
-    keep_as_cancelled = item.status != "completed" and await _is_orders_last_source(db, item)
-
-    await release_budget_reservation(
-        db,
-        source_type="print_queue",
-        source_id=item.id,
-        status="released",
-    )
-    deleted_batch_id = item.batch_id
-    if keep_as_cancelled:
-        item.status = "cancelled"
-        item.completed_at = datetime.now(timezone.utc)
-        await db.flush()
-        # The order may have been sitting on "completed" if this run's target
-        # was met by it; cancelling reopens it.
-        await refresh_batch_status_for_item(db, item.id)
-    else:
-        await db.delete(item)
+    batch_id = item.batch_id
+    keep_as_cancelled = not await _remove_queue_item(db, item)
     await db.commit()
-    if not keep_as_cancelled and deleted_batch_id is not None:
-        # A grouping whose last open job was deleted may be finished now.
-        try:
-            batch = await db.get(PrintBatch, deleted_batch_id)
-            if batch is not None and await refresh_batch_status(db, batch):
-                await db.commit()
-        except Exception as e:
-            logger.warning("Failed to refresh batch %s after a delete: %s", deleted_batch_id, e)
-
-    # Stop an in-flight preheat for this item: the dispatch coroutine is
-    # parked in a sleep and cannot see the status we just wrote (#2727).
-    from backend.app.services.print_scheduler import scheduler as _scheduler
-
-    _scheduler.notify_dispatch_cancelled(item_id)
+    if not keep_as_cancelled:
+        await _refresh_batches_after_delete(db, [batch_id])
+    _notify_dispatch_cancelled([item_id])
 
     if keep_as_cancelled:
         logger.info("Kept queue item %s as cancelled — last source for its batch order plate", item_id)

+ 14 - 1
backend/app/api/routes/printers.py

@@ -635,7 +635,12 @@ async def get_printer_status(
     printer = result.scalar_one_or_none()
     if not printer:
         raise HTTPException(404, "Printer not found")
+    return await _printer_status(db, printer)
 
+
+async def _printer_status(db: AsyncSession, printer: Printer) -> PrinterStatus:
+    """The live status of *printer*, as the status endpoints report it."""
+    printer_id = printer.id
     state = printer_manager.get_status(printer_id)
     if not state:
         # No MQTT client state — the printer was never connected this run, or it
@@ -2603,8 +2608,11 @@ async def get_slot_presets(
 ):
     """Get all saved slot-to-preset mappings for a printer."""
     result = await db.execute(select(SlotPresetMapping).where(SlotPresetMapping.printer_id == printer_id))
-    mappings = result.scalars().all()
+    return _slot_presets_by_key(result.scalars().all())
+
 
+def _slot_presets_by_key(mappings) -> dict:
+    """One printer's slot-to-preset mappings, keyed as ``get_slot_presets`` returns them."""
     return {
         _slot_preset_key(mapping.ams_id, mapping.tray_id): {
             "ams_id": mapping.ams_id,
@@ -3434,6 +3442,11 @@ async def get_ams_labels(
     serial-to-ams_id mapping from the live printer state so the response is still
     keyed by ams_id for UI compatibility.
     """
+    return await _ams_labels(db, printer_id)
+
+
+async def _ams_labels(db: AsyncSession, printer_id: int) -> dict[int, str]:
+    """*printer_id*'s AMS labels keyed by AMS unit id; see ``get_ams_labels``."""
     # Build serial -> ams_id map from live printer state
     serial_to_ams_id: dict[str, int] = {}
     state = printer_manager.get_status(printer_id)

+ 14 - 1
backend/app/api/routes/smart_plugs.py

@@ -286,6 +286,15 @@ async def _plugs_for_printer(db: AsyncSession, printer_id: int) -> list[SmartPlu
     return list(result.scalars().all())
 
 
+class PrinterCardPlugs(BaseModel):
+    """A printer card's power plug and action buttons, in one answer."""
+
+    # As GET /by-printer/{printer_id}: the editor's shape for a caller who may edit plugs
+    plug: SmartPlugResponse | None
+    # As GET /by-printer/{printer_id}/scripts
+    scripts: list[SmartPlugResponse]
+
+
 @router.get("/by-printer/{printer_id}", response_model=None)
 async def get_smart_plug_by_printer(
     printer_id: int,
@@ -321,7 +330,11 @@ async def get_script_plugs_by_printer(
     falls back to showing one of them in the power row -- taking it out of this
     row too would cost the one-click run it has always had there.
     """
-    plugs = await _plugs_for_printer(db, printer_id)
+    return _card_script_plugs(await _plugs_for_printer(db, printer_id))
+
+
+def _card_script_plugs(plugs: list[SmartPlug]) -> list[SmartPlug]:
+    """The action buttons of one printer's card; see ``get_script_plugs_by_printer``."""
     main_plug = _pick_main_plug(plugs)
     duplicate_of_power_row = main_plug.id if main_plug and not _is_script_plug(main_plug) else None
 

+ 2 - 0
backend/app/main.py

@@ -32,6 +32,7 @@ from backend.app.api.routes import (
     archives,
     auth,
     bug_report,
+    bulk,
     camera,
     camwall,
     cloud,
@@ -11847,6 +11848,7 @@ app.include_router(websocket.router, prefix=app_settings.api_prefix)
 app.include_router(discovery.router, prefix=app_settings.api_prefix)
 app.include_router(pending_uploads.router, prefix=app_settings.api_prefix)
 app.include_router(firmware.router, prefix=app_settings.api_prefix)
+app.include_router(bulk.router, prefix=app_settings.api_prefix)
 app.include_router(github_backup.router, prefix=app_settings.api_prefix)
 app.include_router(local_backup.router, prefix=app_settings.api_prefix)
 app.include_router(obico.router, prefix=app_settings.api_prefix)

+ 20 - 0
backend/app/schemas/print_queue.py

@@ -311,6 +311,26 @@ class PrintQueueItemResponse(BaseModel):
         from_attributes = True
 
 
+class PrintQueueHistoryResponse(BaseModel):
+    """One page of the queue history, plus what the page needs around it.
+
+    ``items`` holds the page in display order, followed by any other history
+    runs of the batches on that page, so a batch row can count all of its runs.
+    """
+
+    items: list[PrintQueueItemResponse]
+    # Matching history items in all, not just this page.
+    total: int
+    # Every target_location in the caller's history, for the location filter.
+    locations: list[str]
+
+
+class PrintQueueHistoryClearResponse(BaseModel):
+    cleared: int
+    # Runs a batch order still needs, cancelled rather than deleted (#2960).
+    kept: int
+
+
 class PrintQueueReorderItem(BaseModel):
     id: int
     position: int

+ 29 - 0
backend/app/utils/id_list.py

@@ -0,0 +1,29 @@
+"""Comma-separated id lists for the bulk endpoints that serve page-wide requests."""
+
+from fastapi import HTTPException
+
+# Enough for any farm's Printers page in one request; the frontend batcher
+# splits anything longer.
+MAX_IDS = 500
+
+
+def parse_id_list(value: str) -> list[int]:
+    """Parse ``"3,1,3"`` into ``[3, 1]``: ids in first-seen order, duplicates dropped.
+
+    Raises 422 on anything that isn't a positive integer, or more than
+    ``MAX_IDS`` ids.
+    """
+    ids: list[int] = []
+    for part in value.split(","):
+        part = part.strip()
+        if not part:
+            continue
+        # isdigit() alone also passes "²" (which int() then rejects) and other
+        # scripts' digits
+        if not (part.isascii() and part.isdigit()) or int(part) <= 0:
+            raise HTTPException(422, f"Invalid id: {part!r}")
+        if int(part) not in ids:
+            ids.append(int(part))
+    if len(ids) > MAX_IDS:
+        raise HTTPException(422, f"At most {MAX_IDS} ids per request")
+    return ids

+ 21 - 0
backend/tests/integration/test_api_key_own_rows.py

@@ -381,3 +381,24 @@ class TestKeysThatReachEveryRow:
         )
 
         assert patched.status_code == 403
+
+
+class TestQueueHistoryClear:
+    async def test_a_key_that_reads_own_runs_clears_only_those(self, async_client, db_session, rows):
+        """Its owner may delete every run but read only their own: the History tab
+        it clears is theirs, as for the owner signed in."""
+        group = await db_session.scalar(select(Group).where(Group.name == "own-queuers"))
+        group.permissions = [*group.permissions, "queue:delete_all"]
+        for item in (rows["own_item"], rows["other_item"]):
+            item.status = "completed"
+        await db_session.commit()
+        own_id, other_id = rows["own_item"].id, rows["other_item"].id
+        key = await _key(db_session, rows["owner"].id)
+
+        response = await async_client.post("/api/v1/queue/history/clear", headers=key)
+
+        assert response.status_code == 200, response.text
+        assert response.json() == {"cleared": 1, "kept": 0}
+        db_session.expire_all()
+        assert await db_session.get(PrintQueueItem, own_id) is None
+        assert await db_session.get(PrintQueueItem, other_id) is not None

+ 445 - 0
backend/tests/integration/test_bulk_reads_api.py

@@ -0,0 +1,445 @@
+"""Bulk reads (/api/v1/bulk/...) answer exactly what their single-id routes do.
+
+The Printers page asked for each printer's status, slot presets, AMS labels,
+plugs, sensor readings, firmware and queue once per printer, and the Archives
+page for each card's folders once per archive; the frontend now batches those
+calls into these routes. Each test compares a bulk answer with the single
+route's answer for the same ids, and pins what a bulk answer leaves out: ids
+the caller may not see, which the client then asks the single route for.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+BULK = "/api/v1/bulk"
+
+
+def _auth(jwt: str) -> dict[str, str]:
+    return {"Authorization": f"Bearer {jwt}"}
+
+
+async def _admin_token(async_client: AsyncClient) -> str:
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": "bulkadmin", "admin_password": "AdminPass1!"},
+    )
+    login = await async_client.post("/api/v1/auth/login", json={"username": "bulkadmin", "password": "AdminPass1!"})
+    assert login.status_code == 200, login.text
+    return login.json()["access_token"]
+
+
+async def _user(async_client, admin_jwt, username, permissions, printer_ids=None) -> tuple[str, int]:
+    groups = []
+    perms = await async_client.post(
+        "/api/v1/groups/", headers=_auth(admin_jwt), json={"name": f"perms_{username}", "permissions": permissions}
+    )
+    assert perms.status_code == 201, perms.text
+    groups.append(perms.json()["id"])
+    if printer_ids is not None:
+        team = await async_client.post(
+            "/api/v1/groups/",
+            headers=_auth(admin_jwt),
+            json={"name": f"team_{username}", "permissions": [], "restrict_printers": True, "printer_ids": printer_ids},
+        )
+        assert team.status_code == 201, team.text
+        groups.append(team.json()["id"])
+    created = await async_client.post(
+        "/api/v1/users/",
+        headers=_auth(admin_jwt),
+        json={"username": username, "password": "UserPass1!", "group_ids": groups},
+    )
+    assert created.status_code in (200, 201), created.text
+    login = await async_client.post("/api/v1/auth/login", json={"username": username, "password": "UserPass1!"})
+    assert login.status_code == 200, login.text
+    return login.json()["access_token"], created.json()["id"]
+
+
+async def _get(async_client: AsyncClient, url: str, **kwargs):
+    response = await async_client.get(url, **kwargs)
+    assert response.status_code == 200, f"{url}: {response.text}"
+    return response.json()
+
+
+def _live_state(**overrides) -> MagicMock:
+    """A connected printer's state, enough for the status route to build its answer."""
+    state = MagicMock(
+        connected=True,
+        state="IDLE",
+        current_print=None,
+        subtask_name=None,
+        gcode_file=None,
+        progress=0,
+        remaining_time=None,
+        layer_num=0,
+        total_layers=0,
+        temperatures={"nozzle": 25.0, "bed": 25.0},
+        raw_data={"ams": [{"id": 0, "sn": "AMS-SN-1", "tray": []}]},
+        hms_errors=[],
+        firmware_version="01.08.00.00",
+        developer_mode=True,
+        ams_filament_backup=None,
+        fila_switch=None,
+        subtask_id=None,
+        kprofiles=[],
+        nozzles=[],
+        ams_switch_inlet={},
+    )
+    for key, value in overrides.items():
+        setattr(state, key, value)
+    return state
+
+
+class TestPrinterStatuses:
+    async def test_matches_the_single_route(self, async_client, printer_factory):
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+
+        bulk = await _get(async_client, f"{BULK}/printer-statuses", params={"ids": f"{b.id},{a.id}"})
+
+        # In the order asked for, each the single route's answer
+        assert [row["id"] for row in bulk] == [b.id, a.id]
+        for row in bulk:
+            assert row == await _get(async_client, f"/api/v1/printers/{row['id']}/status")
+
+    async def test_matches_the_single_route_for_a_printing_printer(
+        self, async_client, printer_factory, archive_factory
+    ):
+        # The full status path: live state, the cover, and the active print's archive
+        from backend.app.services.bambu_mqtt import PrinterState
+
+        printing, idle = await printer_factory(name="Busy"), await printer_factory(name="Idle")
+        archive = await archive_factory(printing.id, subtask_id="sub-42")
+        states = {}
+        for printer, run_state in ((printing, "RUNNING"), (idle, "IDLE")):
+            state = PrinterState()
+            state.connected = True
+            state.state = run_state
+            states[printer.id] = state
+        states[printing.id].gcode_file = "/data/Metadata/plate_1.gcode"
+        states[printing.id].subtask_id = "sub-42"
+        states[printing.id].progress = 40
+
+        with patch("backend.app.api.routes.printers.printer_manager") as manager:
+            manager.get_status = MagicMock(side_effect=lambda pid: states.get(pid))
+            manager.is_awaiting_plate_clear = MagicMock(return_value=False)
+            bulk = await _get(async_client, f"{BULK}/printer-statuses", params={"ids": f"{printing.id},{idle.id}"})
+            singles = [await _get(async_client, f"/api/v1/printers/{pid}/status") for pid in (printing.id, idle.id)]
+
+        assert bulk == singles
+        assert bulk[0]["current_archive_id"] == archive.id
+        assert bulk[0]["cover_url"] == f"/api/v1/printers/{printing.id}/cover"
+        assert bulk[1]["connected"] is True
+
+    async def test_unknown_printer_is_left_out(self, async_client, printer_factory):
+        printer = await printer_factory()
+
+        bulk = await _get(async_client, f"{BULK}/printer-statuses", params={"ids": f"{printer.id},99999"})
+
+        assert [row["id"] for row in bulk] == [printer.id]
+
+    async def test_printer_outside_the_scope_is_left_out(self, async_client, printer_factory):
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        jwt, _ = await _user(async_client, admin, "member", ["printers:read"], printer_ids=[a.id])
+
+        bulk = await _get(
+            async_client, f"{BULK}/printer-statuses", params={"ids": f"{a.id},{b.id}"}, headers=_auth(jwt)
+        )
+
+        assert [row["id"] for row in bulk] == [a.id]
+
+    async def test_needs_printers_read(self, async_client, printer_factory):
+        printer = await printer_factory()
+        admin = await _admin_token(async_client)
+        jwt, _ = await _user(async_client, admin, "nobody", ["queue:read_all"])
+
+        response = await async_client.get(
+            f"{BULK}/printer-statuses", params={"ids": str(printer.id)}, headers=_auth(jwt)
+        )
+
+        assert response.status_code == 403
+
+    @pytest.mark.parametrize("ids", ["abc", "1,x", "-1", "0", ",".join(str(i) for i in range(1, 502))])
+    async def test_rejects_bad_id_lists(self, async_client, ids):
+        response = await async_client.get(f"{BULK}/printer-statuses", params={"ids": ids})
+
+        assert response.status_code == 422
+
+
+class TestSlotPresetsAndAmsLabels:
+    async def test_slot_presets_match_the_single_route(self, async_client, db_session, printer_factory):
+        from backend.app.models.slot_preset import SlotPresetMapping
+
+        a, b = await printer_factory(), await printer_factory()
+        db_session.add_all(
+            [
+                SlotPresetMapping(printer_id=a.id, ams_id=0, tray_id=1, preset_id="GFA00", preset_name="PLA"),
+                SlotPresetMapping(printer_id=a.id, ams_id=255, tray_id=0, preset_id="GFB00", preset_name="ABS"),
+            ]
+        )
+        await db_session.commit()
+
+        bulk = await _get(async_client, f"{BULK}/slot-presets", params={"ids": f"{a.id},{b.id}"})
+
+        assert set(bulk) == {str(a.id), str(b.id)}
+        for printer_id in (a.id, b.id):
+            assert bulk[str(printer_id)] == await _get(async_client, f"/api/v1/printers/{printer_id}/slot-presets")
+        assert bulk[str(b.id)] == {}
+
+    async def test_ams_labels_match_the_single_route(self, async_client, db_session, printer_factory):
+        from backend.app.models.ams_label import AmsLabel
+
+        a, b = await printer_factory(), await printer_factory()
+        db_session.add(AmsLabel(ams_serial_number="AMS-SN-1", label="Left rack"))
+        await db_session.commit()
+
+        with patch("backend.app.api.routes.printers.printer_manager") as manager:
+            manager.get_status = MagicMock(
+                side_effect=lambda pid: _live_state() if pid == a.id else None,
+            )
+            bulk = await _get(async_client, f"{BULK}/ams-labels", params={"ids": f"{a.id},{b.id}"})
+            singles = {pid: await _get(async_client, f"/api/v1/printers/{pid}/ams-labels") for pid in (a.id, b.id)}
+
+        assert bulk == {str(pid): answer for pid, answer in singles.items()}
+        assert bulk[str(a.id)] == {"0": "Left rack"}
+
+
+class TestCardPlugs:
+    async def test_matches_the_two_single_routes(self, async_client, printer_factory, smart_plug_factory):
+        a, b = await printer_factory(), await printer_factory()
+        await smart_plug_factory(name="Outlet", printer_id=a.id, ip_address="10.0.0.5")
+        await smart_plug_factory(
+            name="Lights",
+            plug_type="homeassistant",
+            ha_entity_id="script.lights",
+            printer_id=a.id,
+            show_on_printer_card=True,
+        )
+
+        bulk = await _get(async_client, f"{BULK}/card-plugs", params={"ids": f"{a.id},{b.id}"})
+
+        for printer_id in (a.id, b.id):
+            assert bulk[str(printer_id)]["plug"] == await _get(
+                async_client, f"/api/v1/smart-plugs/by-printer/{printer_id}"
+            )
+            assert bulk[str(printer_id)]["scripts"] == await _get(
+                async_client, f"/api/v1/smart-plugs/by-printer/{printer_id}/scripts"
+            )
+        assert bulk[str(a.id)]["plug"]["name"] == "Outlet"
+        assert [p["name"] for p in bulk[str(a.id)]["scripts"]] == ["Lights"]
+        assert bulk[str(b.id)] == {"plug": None, "scripts": []}
+
+    @pytest.mark.parametrize("permissions", [["smart_plugs:read"], ["smart_plugs:read", "smart_plugs:update"]])
+    async def test_a_rest_plug_comes_in_the_callers_shape(
+        self, async_client, printer_factory, smart_plug_factory, permissions
+    ):
+        """A reader gets no REST URLs, an editor gets them: as the single route answers each."""
+        printer = await printer_factory()
+        await smart_plug_factory(
+            name="Rest",
+            plug_type="rest",
+            ip_address=None,
+            printer_id=printer.id,
+            rest_method="GET",
+            rest_on_url="http://plug.elsewhere.example/relay/0?turn=on",
+            rest_off_url="http://plug.elsewhere.example/relay/0?turn=off",
+        )
+        admin = await _admin_token(async_client)
+        jwt, _ = await _user(async_client, admin, "plugs", permissions)
+
+        bulk = await _get(async_client, f"{BULK}/card-plugs", params={"ids": str(printer.id)}, headers=_auth(jwt))
+        single = await _get(async_client, f"/api/v1/smart-plugs/by-printer/{printer.id}", headers=_auth(jwt))
+
+        assert bulk[str(printer.id)]["plug"] == single
+        editor = "smart_plugs:update" in permissions
+        assert ("plug.elsewhere.example" in str(single)) is editor
+
+
+class TestHaSensorReadings:
+    async def test_matches_the_single_route(self, async_client, db_session, printer_factory):
+        from backend.app.models.printer_ha_sensor import PrinterHASensor
+
+        a, b = await printer_factory(), await printer_factory()
+        db_session.add_all(
+            [
+                PrinterHASensor(printer_id=a.id, name="Door", entity_id="binary_sensor.door", sort_order=2),
+                PrinterHASensor(printer_id=a.id, name="Smoke", entity_id="binary_sensor.smoke", sort_order=1),
+                PrinterHASensor(
+                    printer_id=a.id, name="Hidden", entity_id="binary_sensor.hidden", show_on_printer_card=False
+                ),
+            ]
+        )
+        await db_session.commit()
+
+        bulk = await _get(async_client, f"{BULK}/ha-sensor-readings", params={"ids": f"{a.id},{b.id}"})
+
+        for printer_id in (a.id, b.id):
+            assert bulk[str(printer_id)] == await _get(
+                async_client, f"/api/v1/ha-sensors/by-printer/{printer_id}/readings"
+            )
+        assert [r["name"] for r in bulk[str(a.id)]] == ["Smoke", "Door"]
+        assert bulk[str(b.id)] == []
+
+
+class TestFirmwareUpdates:
+    async def test_matches_the_single_route(self, async_client, printer_factory):
+        a, b = await printer_factory(model="X1C"), await printer_factory(model="P1S")
+        service = MagicMock()
+        service.check_for_update = AsyncMock(
+            return_value={
+                "update_available": True,
+                "latest_version": "01.09.00.00",
+                "download_url": None,
+                "release_notes": None,
+                "available_versions": [],
+            }
+        )
+
+        with (
+            patch("backend.app.api.routes.bulk.get_firmware_service", return_value=service),
+            patch("backend.app.api.routes.firmware.get_firmware_service", return_value=service),
+        ):
+            bulk = await _get(async_client, f"{BULK}/firmware-updates", params={"ids": f"{a.id},{b.id},99999"})
+            singles = [await _get(async_client, f"/api/v1/firmware/updates/{pid}") for pid in (a.id, b.id)]
+
+        assert bulk == singles
+
+    async def test_switched_off_asks_nobody(self, async_client, db_session, printer_factory):
+        from backend.app.models.settings import Settings
+
+        a, b = await printer_factory(), await printer_factory()
+        db_session.add(Settings(key="check_printer_firmware", value="false"))
+        await db_session.commit()
+        service = MagicMock()
+        service.check_for_update = AsyncMock()
+
+        with patch("backend.app.api.routes.bulk.get_firmware_service", return_value=service):
+            bulk = await _get(async_client, f"{BULK}/firmware-updates", params={"ids": f"{a.id},{b.id}"})
+
+        assert [row["update_available"] for row in bulk] == [False, False]
+        service.check_for_update.assert_not_called()
+
+
+class TestPrinterQueues:
+    async def test_matches_the_single_route_including_model_jobs(
+        self, async_client, db_session, printer_factory, archive_factory
+    ):
+        from backend.app.models.print_queue import PrintQueueItem
+
+        x1_a = await printer_factory(model="X1C")
+        x1_b = await printer_factory(model="X1C")
+        p1 = await printer_factory(model="P1S")
+        archive = await archive_factory(x1_a.id)
+        db_session.add_all(
+            [
+                PrintQueueItem(archive_id=archive.id, printer_id=x1_a.id, status="pending", position=3),
+                PrintQueueItem(
+                    archive_id=archive.id, printer_id=None, target_model="x1c", status="pending", position=1
+                ),
+                PrintQueueItem(archive_id=archive.id, printer_id=p1.id, status="pending", position=2),
+                PrintQueueItem(archive_id=archive.id, printer_id=x1_a.id, status="printing", position=4),
+                PrintQueueItem(archive_id=archive.id, printer_id=x1_b.id, status="completed", position=5),
+            ]
+        )
+        await db_session.commit()
+        ids = [x1_a.id, x1_b.id, p1.id]
+
+        for status in ("pending", "printing"):
+            bulk = await _get(
+                async_client,
+                f"{BULK}/printer-queues",
+                params={"ids": ",".join(map(str, ids)), "status": status},
+            )
+            for printer_id in ids:
+                single = await _get(async_client, "/api/v1/queue/", params={"printer_id": printer_id, "status": status})
+                assert bulk[str(printer_id)] == single, (status, printer_id)
+
+        pending = await _get(
+            async_client, f"{BULK}/printer-queues", params={"ids": ",".join(map(str, ids)), "status": "pending"}
+        )
+        # The "Any X1C" job sits under both X1Cs, ahead of the pinned one
+        assert [i["printer_id"] for i in pending[str(x1_a.id)]] == [None, x1_a.id]
+        assert [i["printer_id"] for i in pending[str(x1_b.id)]] == [None]
+        assert [i["printer_id"] for i in pending[str(p1.id)]] == [p1.id]
+
+    async def test_read_own_sees_only_own_jobs(self, async_client, db_session, printer_factory, archive_factory):
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+        admin = await _admin_token(async_client)
+        jwt, user_id = await _user(async_client, admin, "owner", ["queue:read_own"])
+        db_session.add_all(
+            [
+                PrintQueueItem(archive_id=archive.id, printer_id=printer.id, status="pending", created_by_id=user_id),
+                PrintQueueItem(archive_id=archive.id, printer_id=printer.id, status="pending"),
+            ]
+        )
+        await db_session.commit()
+
+        bulk = await _get(
+            async_client,
+            f"{BULK}/printer-queues",
+            params={"ids": str(printer.id), "status": "pending"},
+            headers=_auth(jwt),
+        )
+
+        assert [i["created_by_id"] for i in bulk[str(printer.id)]] == [user_id]
+
+
+class TestArchiveFolders:
+    async def test_matches_the_single_route(self, async_client, db_session, printer_factory, archive_factory):
+        from backend.app.models.library import LibraryFolder
+
+        printer = await printer_factory()
+        linked, unlinked = await archive_factory(printer.id), await archive_factory(printer.id)
+        db_session.add_all(
+            [
+                LibraryFolder(name="Zeta", archive_id=linked.id),
+                LibraryFolder(name="Alpha", archive_id=linked.id),
+                LibraryFolder(name="Elsewhere"),
+            ]
+        )
+        await db_session.commit()
+
+        bulk = await _get(async_client, f"{BULK}/archive-folders", params={"ids": f"{linked.id},{unlinked.id}"})
+
+        for archive_id in (linked.id, unlinked.id):
+            assert bulk[str(archive_id)] == await _get(async_client, f"/api/v1/library/folders/by-archive/{archive_id}")
+        assert [f["name"] for f in bulk[str(linked.id)]] == ["Alpha", "Zeta"]
+        assert bulk[str(unlinked.id)] == []
+
+    async def test_an_archive_on_a_printer_outside_the_scope_has_none(
+        self, async_client, db_session, printer_factory, archive_factory
+    ):
+        from backend.app.models.library import LibraryFolder
+
+        mine, theirs = await printer_factory(name="Mine"), await printer_factory(name="Theirs")
+        own_archive, other_archive = await archive_factory(mine.id), await archive_factory(theirs.id)
+        db_session.add_all(
+            [
+                LibraryFolder(name="Own", archive_id=own_archive.id),
+                LibraryFolder(name="Other", archive_id=other_archive.id),
+            ]
+        )
+        await db_session.commit()
+        admin = await _admin_token(async_client)
+        jwt, _ = await _user(async_client, admin, "member", ["library:read_all"], printer_ids=[mine.id])
+
+        bulk = await _get(
+            async_client,
+            f"{BULK}/archive-folders",
+            params={"ids": f"{own_archive.id},{other_archive.id}"},
+            headers=_auth(jwt),
+        )
+
+        assert [f["name"] for f in bulk[str(own_archive.id)]] == ["Own"]
+        assert bulk[str(other_archive.id)] == []
+        assert bulk[str(other_archive.id)] == await _get(
+            async_client, f"/api/v1/library/folders/by-archive/{other_archive.id}", headers=_auth(jwt)
+        )

+ 476 - 0
backend/tests/integration/test_queue_history_api.py

@@ -0,0 +1,476 @@
+"""Queue history, paged on the server (GET /queue/history, POST /queue/history/clear).
+
+The Queue page used to load the whole queue every five seconds and split off
+the history in the browser. On a print farm that was thousands of rows and
+stalled the server for over a minute. These tests pin that the server-side
+version shows the same rows, in the same order, under the same filters, and
+that clearing it keeps the rule the one-by-one DELETEs followed (#2960).
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+
+import pytest
+from httpx import AsyncClient
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+URL = "/api/v1/queue/history"
+T0 = datetime(2026, 10, 1, 12, 0)
+
+
+@pytest.fixture
+def queue_item_factory(db_session):
+    position = [0]
+
+    async def _create(archive_id: int | None, printer_id: int | None = None, **kwargs):
+        from backend.app.models.print_queue import PrintQueueItem
+
+        position[0] += 1
+        defaults = {
+            "archive_id": archive_id,
+            "printer_id": printer_id,
+            "status": "completed",
+            "position": position[0],
+            "created_at": T0,
+        }
+        defaults.update(kwargs)
+        item = PrintQueueItem(**defaults)
+        db_session.add(item)
+        await db_session.commit()
+        await db_session.refresh(item)
+        return item
+
+    return _create
+
+
+async def _ids(async_client: AsyncClient, **params) -> list[int]:
+    response = await async_client.get(URL, params=params)
+    assert response.status_code == 200, response.text
+    return [row["id"] for row in response.json()["items"]]
+
+
+class TestListing:
+    async def test_only_history_statuses(self, async_client, printer_factory, archive_factory, queue_item_factory):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+        history = [
+            await queue_item_factory(archive.id, printer.id, status=status)
+            for status in ("completed", "failed", "skipped", "cancelled")
+        ]
+        await queue_item_factory(archive.id, printer.id, status="pending")
+        await queue_item_factory(archive.id, printer.id, status="printing")
+
+        response = await async_client.get(URL)
+
+        assert response.status_code == 200, response.text
+        assert sorted(row["id"] for row in response.json()["items"]) == sorted(i.id for i in history)
+        assert response.json()["total"] == 4
+
+    async def test_newest_first_by_completion_then_creation(
+        self, async_client, printer_factory, archive_factory, queue_item_factory
+    ):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+        old = await queue_item_factory(archive.id, printer.id, completed_at=T0)
+        never_finished = await queue_item_factory(
+            archive.id, printer.id, status="cancelled", created_at=T0 + timedelta(hours=2)
+        )
+        new = await queue_item_factory(archive.id, printer.id, completed_at=T0 + timedelta(hours=3))
+
+        assert await _ids(async_client) == [new.id, never_finished.id, old.id]
+        assert await _ids(async_client, reverse=True) == [old.id, never_finished.id, new.id]
+
+    async def test_sort_by_name_is_a_to_z_and_ignores_case(
+        self, async_client, printer_factory, archive_factory, queue_item_factory
+    ):
+        printer = await printer_factory()
+        names = {}
+        for name in ("bracket", "Anchor", "Clip"):
+            archive = await archive_factory(printer.id, print_name=name)
+            names[name] = (await queue_item_factory(archive.id, printer.id)).id
+
+        assert await _ids(async_client, sort_by="name") == [names["Anchor"], names["bracket"], names["Clip"]]
+        assert await _ids(async_client, sort_by="name", reverse=True) == [
+            names["Clip"],
+            names["bracket"],
+            names["Anchor"],
+        ]
+
+    async def test_sort_by_name_skips_a_deleted_archive(
+        self, async_client, printer_factory, archive_factory, queue_item_factory
+    ):
+        # A deleted archive's name isn't shown on the row, so it can't order it
+        printer = await printer_factory()
+        gone = await archive_factory(printer.id, print_name="Aaa", filename="zzz.3mf", deleted_at=T0)
+        live = await archive_factory(printer.id, print_name="Mmm")
+        gone_row = await queue_item_factory(gone.id, printer.id)
+        live_row = await queue_item_factory(live.id, printer.id)
+
+        # No archive and no library file: sorts as an empty name, first
+        assert await _ids(async_client, sort_by="name") == [gone_row.id, live_row.id]
+
+    async def test_sort_by_printer(self, async_client, printer_factory, archive_factory, queue_item_factory):
+        zeta, alpha = await printer_factory(name="Zeta"), await printer_factory(name="alpha")
+        archive = await archive_factory(zeta.id)
+        on_zeta = await queue_item_factory(archive.id, zeta.id)
+        on_alpha = await queue_item_factory(archive.id, alpha.id)
+
+        assert await _ids(async_client, sort_by="printer") == [on_alpha.id, on_zeta.id]
+
+    async def test_pages_with_total(self, async_client, printer_factory, archive_factory, queue_item_factory):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+        rows = [
+            await queue_item_factory(archive.id, printer.id, completed_at=T0 + timedelta(minutes=i)) for i in range(5)
+        ]
+        newest_first = [r.id for r in reversed(rows)]
+
+        response = await async_client.get(URL, params={"limit": 2, "offset": 2})
+
+        assert response.status_code == 200, response.text
+        assert [row["id"] for row in response.json()["items"]] == newest_first[2:4]
+        assert response.json()["total"] == 5
+
+    async def test_page_brings_the_other_runs_of_its_batches(
+        self, async_client, db_session, printer_factory, archive_factory, queue_item_factory
+    ):
+        from backend.app.models.print_batch import PrintBatch
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+        batch = PrintBatch(name="Order", status="active")
+        db_session.add(batch)
+        await db_session.commit()
+        older_run = await queue_item_factory(archive.id, printer.id, batch_id=batch.id, completed_at=T0)
+        await queue_item_factory(archive.id, printer.id, completed_at=T0 + timedelta(hours=1))
+        newer_run = await queue_item_factory(
+            archive.id, printer.id, batch_id=batch.id, completed_at=T0 + timedelta(hours=2)
+        )
+
+        response = await async_client.get(URL, params={"limit": 1})
+
+        assert response.status_code == 200, response.text
+        # The page first, then the rest of its batch; the unbatched row stays out
+        assert [row["id"] for row in response.json()["items"]] == [newer_run.id, older_run.id]
+        assert response.json()["total"] == 3
+
+
+class TestFilters:
+    async def test_printer_and_unassigned(self, async_client, printer_factory, archive_factory, queue_item_factory):
+        a, b = await printer_factory(), await printer_factory()
+        archive = await archive_factory(a.id)
+        on_a = await queue_item_factory(archive.id, a.id)
+        await queue_item_factory(archive.id, b.id)
+        unassigned = await queue_item_factory(archive.id, None, status="cancelled", target_model="P2S")
+
+        assert await _ids(async_client, printer_id=a.id) == [on_a.id]
+        assert await _ids(async_client, printer_id=-1) == [unassigned.id]
+
+    async def test_status(self, async_client, printer_factory, archive_factory, queue_item_factory):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+        failed = await queue_item_factory(archive.id, printer.id, status="failed")
+        await queue_item_factory(archive.id, printer.id, status="completed")
+        await queue_item_factory(archive.id, printer.id, status="pending")
+
+        assert await _ids(async_client, status="failed") == [failed.id]
+        # An active status selected on the page leaves no history to show
+        assert await _ids(async_client, status="pending") == []
+
+    async def test_location_by_target_location_or_printer(
+        self, async_client, printer_factory, archive_factory, queue_item_factory
+    ):
+        hall, lab = await printer_factory(location="Hall"), await printer_factory(location="Lab")
+        archive = await archive_factory(hall.id)
+        in_hall = await queue_item_factory(archive.id, hall.id)
+        await queue_item_factory(archive.id, lab.id)
+        model_based_hall = await queue_item_factory(archive.id, None, target_model="P2S", target_location="Hall")
+        # A target location wins over the printer's own
+        await queue_item_factory(archive.id, hall.id, target_location="Lab")
+
+        assert sorted(await _ids(async_client, location="Hall")) == sorted([in_hall.id, model_based_hall.id])
+
+    async def test_location_filter_with_printer_sort(
+        self, async_client, printer_factory, archive_factory, queue_item_factory
+    ):
+        # The printer sort joins printers; the location check must not get tangled in it
+        hall, lab = await printer_factory(name="B", location="Hall"), await printer_factory(name="A", location="Lab")
+        archive = await archive_factory(hall.id)
+        in_hall = await queue_item_factory(archive.id, hall.id)
+        await queue_item_factory(archive.id, lab.id)
+
+        assert await _ids(async_client, location="Hall", sort_by="printer") == [in_hall.id]
+
+    async def test_locations_lists_every_target_location(
+        self, async_client, printer_factory, archive_factory, queue_item_factory
+    ):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+        await queue_item_factory(archive.id, None, target_model="P2S", target_location="Hall")
+        await queue_item_factory(archive.id, None, target_model="P2S", target_location="Annex")
+        await queue_item_factory(archive.id, None, target_model="P2S", target_location="")
+        await queue_item_factory(archive.id, printer.id)
+
+        response = await async_client.get(URL, params={"status": "failed", "limit": 1})
+
+        assert response.status_code == 200, response.text
+        # Independent of the filters and the page
+        assert response.json()["locations"] == ["Annex", "Hall"]
+
+
+class TestActiveListing:
+    async def test_list_takes_several_statuses(
+        self, async_client, printer_factory, archive_factory, queue_item_factory
+    ):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+        pending = await queue_item_factory(archive.id, printer.id, status="pending")
+        printing = await queue_item_factory(archive.id, printer.id, status="printing")
+        await queue_item_factory(archive.id, printer.id, status="completed")
+
+        response = await async_client.get("/api/v1/queue/", params={"status": "pending,printing"})
+
+        assert response.status_code == 200, response.text
+        assert sorted(row["id"] for row in response.json()) == sorted([pending.id, printing.id])
+
+    async def test_list_single_status_unchanged(
+        self, async_client, printer_factory, archive_factory, queue_item_factory
+    ):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+        pending = await queue_item_factory(archive.id, printer.id, status="pending")
+        await queue_item_factory(archive.id, printer.id, status="completed")
+
+        response = await async_client.get("/api/v1/queue/", params={"status": "pending"})
+
+        assert [row["id"] for row in response.json()] == [pending.id]
+
+
+class TestClear:
+    async def test_clears_only_history_under_the_filters(
+        self, async_client, db_session, printer_factory, archive_factory, queue_item_factory
+    ):
+        from backend.app.models.print_queue import PrintQueueItem
+
+        a, b = await printer_factory(), await printer_factory()
+        archive = await archive_factory(a.id)
+        done_on_a = await queue_item_factory(archive.id, a.id)
+        failed_on_a = await queue_item_factory(archive.id, a.id, status="failed")
+        done_on_b = await queue_item_factory(archive.id, b.id)
+        pending_on_a = await queue_item_factory(archive.id, a.id, status="pending")
+
+        ids = {r: r.id for r in (done_on_a, failed_on_a, done_on_b, pending_on_a)}
+
+        response = await async_client.post(f"{URL}/clear", params={"printer_id": a.id})
+
+        assert response.status_code == 200, response.text
+        assert response.json() == {"cleared": 2, "kept": 0}
+        db_session.expire_all()
+        assert await db_session.get(PrintQueueItem, ids[done_on_a]) is None
+        assert await db_session.get(PrintQueueItem, ids[failed_on_a]) is None
+        assert await db_session.get(PrintQueueItem, ids[done_on_b]) is not None
+        assert await db_session.get(PrintQueueItem, ids[pending_on_a]) is not None
+
+    async def test_keeps_an_orders_last_run_as_cancelled(
+        self, async_client, db_session, printer_factory, archive_factory
+    ):
+        """The same rule as deleting the run on its own (#2960)."""
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+        order = await async_client.post(
+            "/api/v1/queue/batches",
+            json={"name": "Order", "archive_id": archive.id, "plates": [{"plate_id": 1, "quantity_target": 3}]},
+        )
+        assert order.status_code == 200, order.text
+        runs = []
+        for _ in range(2):
+            created = await async_client.post(
+                "/api/v1/queue/",
+                json={
+                    "printer_id": printer.id,
+                    "archive_id": archive.id,
+                    "batch_id": order.json()["id"],
+                    "plate_id": 1,
+                },
+            )
+            assert created.status_code == 200, created.text
+            runs.append(created.json()["id"])
+        for run_id in runs:
+            row = await db_session.get(PrintQueueItem, run_id)
+            row.status = "failed"
+        await db_session.commit()
+
+        response = await async_client.post(f"{URL}/clear")
+
+        assert response.status_code == 200, response.text
+        assert response.json() == {"cleared": 1, "kept": 1}
+        db_session.expire_all()
+        # The later of the two, in queue order, is the one left to clone
+        assert await db_session.get(PrintQueueItem, runs[0]) is None
+        survivor = await db_session.get(PrintQueueItem, runs[1])
+        assert survivor is not None
+        assert survivor.status == "cancelled"
+        # Ended now, as a cancel on its own stamps it
+        assert survivor.completed_at is not None
+
+    async def test_a_grouping_whose_last_open_job_is_cleared_is_finished(
+        self, async_client, db_session, printer_factory, archive_factory
+    ):
+        """As deleting that job on its own finishes it."""
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+        ids = []
+        for _ in range(2):
+            created = await async_client.post(
+                "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id}
+            )
+            ids.append(created.json()["id"])
+        grouping = (await async_client.post("/api/v1/queue/batches", json={"name": "G", "item_ids": ids})).json()
+        assert (await async_client.post(f"/api/v1/queue/{ids[0]}/cancel")).status_code == 200
+        # Skipped is still open: the grouping waits for it
+        row = await db_session.get(PrintQueueItem, ids[1])
+        row.status = "skipped"
+        await db_session.commit()
+
+        response = await async_client.post(f"{URL}/clear", params={"status": "skipped"})
+
+        assert response.json() == {"cleared": 1, "kept": 0}
+        batch = await async_client.get(f"/api/v1/queue/batches/{grouping['id']}")
+        assert batch.json()["status"] == "cancelled"
+
+
+def _auth(jwt: str) -> dict[str, str]:
+    return {"Authorization": f"Bearer {jwt}"}
+
+
+async def _admin_token(async_client: AsyncClient) -> str:
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": "histadmin", "admin_password": "AdminPass1!"},
+    )
+    login = await async_client.post("/api/v1/auth/login", json={"username": "histadmin", "password": "AdminPass1!"})
+    assert login.status_code == 200, login.text
+    return login.json()["access_token"]
+
+
+async def _user(
+    async_client: AsyncClient, admin_jwt: str, username: str, permissions: list[str], printer_ids=None
+) -> tuple[str, int]:
+    groups = []
+    perms = await async_client.post(
+        "/api/v1/groups/", headers=_auth(admin_jwt), json={"name": f"perms_{username}", "permissions": permissions}
+    )
+    assert perms.status_code == 201, perms.text
+    groups.append(perms.json()["id"])
+    if printer_ids is not None:
+        team = await async_client.post(
+            "/api/v1/groups/",
+            headers=_auth(admin_jwt),
+            json={"name": f"team_{username}", "permissions": [], "restrict_printers": True, "printer_ids": printer_ids},
+        )
+        assert team.status_code == 201, team.text
+        groups.append(team.json()["id"])
+    created = await async_client.post(
+        "/api/v1/users/",
+        headers=_auth(admin_jwt),
+        json={"username": username, "password": "UserPass1!", "group_ids": groups},
+    )
+    assert created.status_code in (200, 201), created.text
+    login = await async_client.post("/api/v1/auth/login", json={"username": username, "password": "UserPass1!"})
+    assert login.status_code == 200, login.text
+    return login.json()["access_token"], created.json()["id"]
+
+
+class TestAccess:
+    async def test_read_own_sees_only_own_history(
+        self, async_client, printer_factory, archive_factory, queue_item_factory
+    ):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+        admin = await _admin_token(async_client)
+        jwt, user_id = await _user(async_client, admin, "owner", ["queue:read_own"])
+        mine = await queue_item_factory(archive.id, printer.id, created_by_id=user_id)
+        await queue_item_factory(archive.id, printer.id)
+
+        response = await async_client.get(URL, headers=_auth(jwt))
+
+        assert response.status_code == 200, response.text
+        assert [row["id"] for row in response.json()["items"]] == [mine.id]
+        assert response.json()["total"] == 1
+
+    async def test_team_member_sees_only_team_printers(
+        self, async_client, printer_factory, archive_factory, queue_item_factory
+    ):
+        a, b = await printer_factory(), await printer_factory()
+        archive = await archive_factory(a.id)
+        admin = await _admin_token(async_client)
+        jwt, _ = await _user(async_client, admin, "member", ["queue:read_all"], printer_ids=[a.id])
+        on_a = await queue_item_factory(archive.id, a.id)
+        await queue_item_factory(archive.id, b.id)
+
+        response = await async_client.get(URL, headers=_auth(jwt))
+
+        assert [row["id"] for row in response.json()["items"]] == [on_a.id]
+
+    async def test_needs_queue_read(self, async_client):
+        admin = await _admin_token(async_client)
+        jwt, _ = await _user(async_client, admin, "nobody", ["printers:read"])
+
+        assert (await async_client.get(URL, headers=_auth(jwt))).status_code == 403
+        assert (await async_client.post(f"{URL}/clear", headers=_auth(jwt))).status_code == 403
+
+    async def test_read_own_clears_only_own_history_even_with_delete_all(
+        self, async_client, db_session, printer_factory, archive_factory, queue_item_factory
+    ):
+        # The page only ever listed, and so deleted, the rows its user could see
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+        admin = await _admin_token(async_client)
+        jwt, user_id = await _user(async_client, admin, "reader", ["queue:read_own", "queue:delete_all"])
+        mine = await queue_item_factory(archive.id, printer.id, created_by_id=user_id)
+        theirs = await queue_item_factory(archive.id, printer.id)
+        mine_id, theirs_id = mine.id, theirs.id
+
+        response = await async_client.post(f"{URL}/clear", headers=_auth(jwt))
+
+        assert response.status_code == 200, response.text
+        assert response.json() == {"cleared": 1, "kept": 0}
+        db_session.expire_all()
+        assert await db_session.get(PrintQueueItem, mine_id) is None
+        assert await db_session.get(PrintQueueItem, theirs_id) is not None
+
+    async def test_clear_needs_queue_read(self, async_client):
+        admin = await _admin_token(async_client)
+        jwt, _ = await _user(async_client, admin, "deleter", ["queue:delete_all"])
+
+        assert (await async_client.post(f"{URL}/clear", headers=_auth(jwt))).status_code == 403
+
+    async def test_delete_own_clears_only_own_history(
+        self, async_client, db_session, printer_factory, archive_factory, queue_item_factory
+    ):
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+        admin = await _admin_token(async_client)
+        jwt, user_id = await _user(async_client, admin, "owner", ["queue:read_all", "queue:delete_own"])
+        mine = await queue_item_factory(archive.id, printer.id, created_by_id=user_id)
+        theirs = await queue_item_factory(archive.id, printer.id)
+
+        mine_id, theirs_id = mine.id, theirs.id
+
+        response = await async_client.post(f"{URL}/clear", headers=_auth(jwt))
+
+        assert response.status_code == 200, response.text
+        assert response.json() == {"cleared": 1, "kept": 0}
+        db_session.expire_all()
+        assert await db_session.get(PrintQueueItem, mine_id) is None
+        assert await db_session.get(PrintQueueItem, theirs_id) is not None

+ 5 - 1
backend/tests/unit/test_connection_watchdog.py

@@ -12,6 +12,7 @@ it must not interfere with a session that is recovering on its own, and it must
 not churn clients for printers that are simply switched off.
 """
 
+import re
 import time
 from types import SimpleNamespace
 from unittest.mock import AsyncMock, MagicMock, patch
@@ -65,7 +66,10 @@ class TestRebuildsDeadSessions:
     async def test_reconnect_reason_names_the_duration(self):
         client = _client(connected=False, last_message_age=32718.0)
         await _sweep({1: client})
-        assert "32718" in client.force_reconnect_stale_session.call_args.args[0]
+        reason = client.force_reconnect_stale_session.call_args.args[0]
+        # The age is read from the clock, so a slow run may add a second or two
+        seconds = int(re.search(r"offline for (\d+)s", reason).group(1))
+        assert 32718 <= seconds <= 32728, reason
 
     @pytest.mark.asyncio
     async def test_sweeps_every_printer_in_the_farm(self):

+ 28 - 0
backend/tests/unit/test_id_list.py

@@ -0,0 +1,28 @@
+"""parse_id_list: the id lists the bulk routes take."""
+
+import pytest
+from fastapi import HTTPException
+
+from backend.app.utils.id_list import MAX_IDS, parse_id_list
+
+
+def test_parses_in_order_without_duplicates():
+    assert parse_id_list("3,1,3, 2 ,") == [3, 1, 2]
+
+
+def test_empty_is_no_ids():
+    assert parse_id_list("") == []
+
+
+@pytest.mark.parametrize("value", ["abc", "1,x", "-1", "0", "1.5", "²", "١٢", "+3"])
+def test_rejects_anything_but_positive_ascii_integers(value):
+    with pytest.raises(HTTPException) as caught:
+        parse_id_list(value)
+    assert caught.value.status_code == 422
+
+
+def test_caps_the_list():
+    assert len(parse_id_list(",".join(str(i) for i in range(1, MAX_IDS + 1)))) == MAX_IDS
+    with pytest.raises(HTTPException) as caught:
+        parse_id_list(",".join(str(i) for i in range(1, MAX_IDS + 2)))
+    assert caught.value.status_code == 422

+ 198 - 0
frontend/src/__tests__/api/batch.test.ts

@@ -0,0 +1,198 @@
+/**
+ * The per-id read batcher (api/batch.ts): what goes out, and who gets what back.
+ */
+import { describe, it, expect, vi } from 'vitest';
+import { batched, batchesSettled, byField, byId } from '../../api/batch';
+
+class StatusError extends Error {
+  constructor(public status: number) {
+    super(`HTTP ${status}`);
+  }
+}
+
+function setup(options: { bulk?: (ids: number[]) => Promise<Map<number, string>> } = {}) {
+  const single = vi.fn(async (id: number) => `single-${id}`);
+  const many = vi.fn(
+    options.bulk ?? (async (ids: number[]) => new Map(ids.map((id) => [id, `bulk-${id}`] as [number, string]))),
+  );
+  return { single, many, read: batched(single, many) };
+}
+
+describe('batched', () => {
+  it('sends a lone id as the single request', async () => {
+    const { single, many, read } = setup();
+
+    await expect(read(7)).resolves.toBe('single-7');
+    expect(single).toHaveBeenCalledWith(7);
+    expect(many).not.toHaveBeenCalled();
+  });
+
+  it('sends ids asked for together as one bulk request, each caller getting its own answer', async () => {
+    const { single, many, read } = setup();
+
+    const answers = await Promise.all([read(1), read(2), read(3)]);
+
+    expect(answers).toEqual(['bulk-1', 'bulk-2', 'bulk-3']);
+    expect(many).toHaveBeenCalledTimes(1);
+    expect(many).toHaveBeenCalledWith([1, 2, 3]);
+    expect(single).not.toHaveBeenCalled();
+  });
+
+  it('asks for a repeated id once and answers every caller', async () => {
+    const { many, read } = setup();
+
+    const answers = await Promise.all([read(1), read(2), read(1)]);
+
+    expect(answers).toEqual(['bulk-1', 'bulk-2', 'bulk-1']);
+    expect(many).toHaveBeenCalledWith([1, 2]);
+  });
+
+  it('catches calls arriving a few milliseconds apart, as interval refetches do', async () => {
+    const { many, read } = setup();
+
+    const first = read(1);
+    await new Promise((resolve) => setTimeout(resolve, 3));
+    const second = read(2);
+
+    await expect(Promise.all([first, second])).resolves.toEqual(['bulk-1', 'bulk-2']);
+    expect(many).toHaveBeenCalledTimes(1);
+  });
+
+  it('starts a new request for calls after the window closed', async () => {
+    const { many, single, read } = setup();
+
+    await Promise.all([read(1), read(2)]);
+    await read(3);
+
+    expect(many).toHaveBeenCalledTimes(1);
+    expect(single).toHaveBeenCalledWith(3);
+  });
+
+  it('asks the single request for an id the bulk answer left out, passing on its error', async () => {
+    const { single, read } = setup({ bulk: async () => new Map([[1, 'bulk-1']]) });
+    single.mockImplementation(async () => {
+      throw new StatusError(404);
+    });
+
+    const [kept, left] = await Promise.allSettled([read(1), read(2)]);
+
+    expect(kept).toEqual({ status: 'fulfilled', value: 'bulk-1' });
+    expect(left.status).toBe('rejected');
+    expect((left as PromiseRejectedResult).reason.status).toBe(404);
+    expect(single).toHaveBeenCalledWith(2);
+    expect(single).not.toHaveBeenCalledWith(1);
+  });
+
+  it('keeps answering the rest when an id in the middle is left out', async () => {
+    const { read } = setup({ bulk: async () => new Map([[1, 'bulk-1'], [3, 'bulk-3']]) });
+
+    await expect(Promise.all([read(1), read(2), read(3)])).resolves.toEqual(['bulk-1', 'single-2', 'bulk-3']);
+  });
+
+  it.each([404, 405, 422, 500, 503])('falls back to single requests when the bulk route answers %i', async (status) => {
+    const { single, read } = setup({
+      bulk: async () => {
+        throw new StatusError(status);
+      },
+    });
+
+    await expect(Promise.all([read(1), read(2)])).resolves.toEqual(['single-1', 'single-2']);
+    expect(single).toHaveBeenCalledTimes(2);
+  });
+
+  it.each([401, 403])('rejects every caller, sending no single requests, on a %i refusal', async (status) => {
+    const { single, read } = setup({
+      bulk: async () => {
+        throw new StatusError(status);
+      },
+    });
+
+    const results = await Promise.allSettled([read(1), read(2)]);
+
+    expect(results.map((r) => r.status)).toEqual(['rejected', 'rejected']);
+    expect((results[0] as PromiseRejectedResult).reason.status).toBe(status);
+    expect(single).not.toHaveBeenCalled();
+  });
+
+  it('falls back to single requests when the bulk request never got an answer', async () => {
+    const { single, read } = setup({
+      bulk: async () => {
+        throw new TypeError('Failed to fetch');
+      },
+    });
+
+    await expect(Promise.all([read(1), read(2)])).resolves.toEqual(['single-1', 'single-2']);
+    expect(single).toHaveBeenCalledTimes(2);
+  });
+
+  it('confines a failure the server has for one id to that id', async () => {
+    const { single, read } = setup({
+      bulk: async () => {
+        throw new StatusError(500);
+      },
+    });
+    single.mockImplementation(async (id: number) => {
+      if (id === 2) throw new StatusError(500);
+      return `single-${id}`;
+    });
+
+    const results = await Promise.allSettled([read(1), read(2), read(3)]);
+
+    expect(results.map((r) => r.status)).toEqual(['fulfilled', 'rejected', 'fulfilled']);
+  });
+
+  it('splits a very long id list into several bulk requests', async () => {
+    const { many, read } = setup();
+
+    await Promise.all(Array.from({ length: 450 }, (_, i) => read(i + 1)));
+
+    expect(many.mock.calls.map(([ids]) => ids.length)).toEqual([200, 200, 50]);
+  });
+
+  it('reports when every started batch has been answered', async () => {
+    let release: () => void = () => {};
+    const held = new Promise<void>((resolve) => {
+      release = resolve;
+    });
+    const { read } = setup({
+      bulk: async (ids) => {
+        await held;
+        return new Map(ids.map((id) => [id, `bulk-${id}`] as [number, string]));
+      },
+    });
+    let settled = false;
+
+    const reads = Promise.all([read(1), read(2)]);
+    const waiting = batchesSettled().then(() => {
+      settled = true;
+    });
+    await new Promise((resolve) => setTimeout(resolve, 30));
+    expect(settled).toBe(false);
+
+    release();
+    await waiting;
+    expect(settled).toBe(true);
+    await reads;
+  });
+});
+
+describe('byId / byField', () => {
+  it('turns string-keyed JSON into a number-keyed map', () => {
+    expect(byId({ '1': 'a', '22': 'b' })).toEqual(
+      new Map([
+        [1, 'a'],
+        [22, 'b'],
+      ]),
+    );
+  });
+
+  it('keys rows by their own id', () => {
+    const rows = [{ id: 4 }, { id: 9 }];
+    expect(byField(rows, (r) => r.id)).toEqual(
+      new Map([
+        [4, rows[0]],
+        [9, rows[1]],
+      ]),
+    );
+  });
+});

+ 4 - 1
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -495,7 +495,10 @@ describe('PrintModal', () => {
       />
     );
 
-    await user.click(screen.getByRole('button', { name: /save/i }));
+    // Save waits for the printer's live status (#2589)
+    const save = screen.getByRole('button', { name: /save/i });
+    await waitFor(() => expect(save).toBeEnabled());
+    await user.click(save);
 
     await waitFor(() => expect(body).not.toBeNull());
     expect(body!.manual_start).toBe(true);

+ 52 - 0
frontend/src/__tests__/mocks/handlers.ts

@@ -87,7 +87,58 @@ const mockPrinters = [
   },
 ];
 
+// The bulk routes the API client batches per-printer reads into (api/batch.ts)
+// answer by asking the matching single-printer route for each id, through
+// MSW again. A test that mocks the single route therefore shapes the bulk
+// answer too, and ids the single route rejects are left out, as the backend
+// leaves out printers outside the caller's scope.
+async function fanOut(request: Request, single: (id: string) => string): Promise<Map<string, unknown>> {
+  const url = new URL(request.url);
+  const ids = (url.searchParams.get('ids') ?? '').split(',').filter(Boolean);
+  const answers = new Map<string, unknown>();
+  await Promise.all(
+    ids.map(async (id) => {
+      const headers = new Headers(request.headers);
+      // Marks the request as the mock's own, for tests counting what the app sent
+      headers.set('x-test-fan-out', '1');
+      const response = await fetch(new URL(single(id), url), { headers });
+      if (response.ok) answers.set(id, await response.json());
+    }),
+  );
+  return answers;
+}
+
+const asRecord = (answers: Map<string, unknown>) => HttpResponse.json(Object.fromEntries(answers));
+const asList = (answers: Map<string, unknown>) => HttpResponse.json([...answers.values()]);
+
+const bulkHandlers = [
+  http.get('*/api/v1/bulk/printer-statuses', async ({ request }) =>
+    asList(await fanOut(request, (id) => `/api/v1/printers/${id}/status`))),
+  http.get('*/api/v1/bulk/slot-presets', async ({ request }) =>
+    asRecord(await fanOut(request, (id) => `/api/v1/printers/${id}/slot-presets`))),
+  http.get('*/api/v1/bulk/ams-labels', async ({ request }) =>
+    asRecord(await fanOut(request, (id) => `/api/v1/printers/${id}/ams-labels`))),
+  http.get('*/api/v1/bulk/card-plugs', async ({ request }) => {
+    const plugs = await fanOut(request, (id) => `/api/v1/smart-plugs/by-printer/${id}`);
+    const scripts = await fanOut(request, (id) => `/api/v1/smart-plugs/by-printer/${id}/scripts`);
+    return HttpResponse.json(
+      Object.fromEntries([...plugs].map(([id, plug]) => [id, { plug, scripts: scripts.get(id) ?? [] }])),
+    );
+  }),
+  http.get('*/api/v1/bulk/ha-sensor-readings', async ({ request }) =>
+    asRecord(await fanOut(request, (id) => `/api/v1/ha-sensors/by-printer/${id}/readings`))),
+  http.get('*/api/v1/bulk/firmware-updates', async ({ request }) =>
+    asList(await fanOut(request, (id) => `/api/v1/firmware/updates/${id}`))),
+  http.get('*/api/v1/bulk/printer-queues', async ({ request }) => {
+    const status = new URL(request.url).searchParams.get('status') ?? '';
+    return asRecord(await fanOut(request, (id) => `/api/v1/queue/?printer_id=${id}&status=${status}`));
+  }),
+  http.get('*/api/v1/bulk/archive-folders', async ({ request }) =>
+    asRecord(await fanOut(request, (id) => `/api/v1/library/folders/by-archive/${id}`))),
+];
+
 export const handlers = [
+  ...bulkHandlers,
   // ========================================================================
   // Smart Plugs
   // ========================================================================
@@ -443,6 +494,7 @@ export const handlers = [
   // Lists → empty arrays
   http.get('/api/v1/archives/', () => HttpResponse.json([])),
   http.get('/api/v1/archives/last-per-printer', () => HttpResponse.json([])),
+  http.get('/api/v1/queue/history', () => HttpResponse.json({ items: [], total: 0, locations: [] })),
   http.get('/api/v1/auth/oidc/providers', () => HttpResponse.json([])),
   http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([])),
   // OIDC icon proxy (#1333). Default returns a tiny PNG; individual tests

+ 90 - 0
frontend/src/__tests__/pages/PrintersPageBatchedReads.test.tsx

@@ -0,0 +1,90 @@
+/**
+ * The Printers page reads every card's data with one request per kind of data.
+ *
+ * Each card used to ask for its own status, slot presets, AMS labels, plugs,
+ * sensor readings and queue, so a farm's Printers page sent hundreds of
+ * requests on load and the server worked through them one behind the other.
+ * The cards still each run their own query; the API client batches them.
+ */
+import { describe, it, expect, beforeEach } from 'vitest';
+import { waitFor } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import { render } from '../utils';
+import { PrintersPage } from '../../pages/PrintersPage';
+
+const printer = (id: number) => ({
+  id,
+  name: `Printer ${id}`,
+  ip_address: `192.168.1.${100 + id}`,
+  serial_number: `01P00A00000000${id}`,
+  access_code: '12345678',
+  model: 'X1C',
+  enabled: true,
+  is_active: true,
+  nozzle_diameter: 0.4,
+  nozzle_type: 'stainless_steel',
+  location: null,
+  auto_archive: true,
+  created_at: '2024-01-01T00:00:00Z',
+  updated_at: '2024-01-01T00:00:00Z',
+});
+
+const status = (id: number) => ({
+  id,
+  name: `Printer ${id}`,
+  connected: true,
+  state: 'IDLE',
+  progress: 0,
+  layer_num: 0,
+  total_layers: 0,
+  temperatures: { nozzle: 25, bed: 25, chamber: 25 },
+  remaining_time: 0,
+  wifi_signal: -40,
+  speed_level: 2,
+  vt_tray: [],
+  ams: [],
+});
+
+describe('PrintersPage — batched per-printer reads', () => {
+  let paths: string[];
+
+  beforeEach(() => {
+    paths = [];
+    server.events.removeAllListeners('request:start');
+    server.events.on('request:start', ({ request }) => {
+      // The default bulk mocks answer by asking the single routes; not the app's
+      if (request.headers.get('x-test-fan-out')) return;
+      const url = new URL(request.url);
+      paths.push(url.pathname + url.search);
+    });
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([printer(1), printer(2), printer(3)])),
+      http.get('/api/v1/bulk/printer-statuses', () => HttpResponse.json([status(1), status(2), status(3)])),
+    );
+  });
+
+  it('asks for every printer\'s status in one request', async () => {
+    render(<PrintersPage />);
+
+    await waitFor(() =>
+      expect(paths.filter((p) => p.startsWith('/api/v1/bulk/printer-statuses'))).toHaveLength(1),
+    );
+    expect(paths).toContain('/api/v1/bulk/printer-statuses?ids=1,2,3');
+    expect(paths.some((p) => /^\/api\/v1\/printers\/\d+\/status/.test(p))).toBe(false);
+  });
+
+  it('batches the other per-card reads as well', async () => {
+    render(<PrintersPage />);
+
+    await waitFor(() => {
+      for (const route of ['slot-presets', 'ams-labels', 'card-plugs', 'ha-sensor-readings', 'printer-queues']) {
+        expect(paths.some((p) => p.startsWith(`/api/v1/bulk/${route}?ids=1,2,3`))).toBe(true);
+      }
+    });
+    const perPrinter = paths.filter((p) =>
+      /^\/api\/v1\/(printers\/\d+\/(slot-presets|ams-labels)|smart-plugs\/by-printer\/\d+|ha-sensors\/by-printer\/\d+)/.test(p),
+    );
+    expect(perPrinter).toEqual([]);
+  });
+});

+ 3 - 0
frontend/src/__tests__/pages/PrintersPageCameraSplitButton.test.tsx

@@ -93,6 +93,9 @@ function renderPage(storedMode: 'window' | 'embedded' = 'window') {
 /** The camera icon, whichever of the two modes it currently promises. */
 async function cameraIcon(): Promise<HTMLElement> {
   await waitFor(() => expect(document.getElementById('printer-card-1')).not.toBeNull());
+  // The camera controls stay disabled until the printer's status says it is
+  // connected; status arrives a moment after the card.
+  await waitFor(() => expect(screen.getByLabelText('Camera View Mode')).not.toBeDisabled());
   const el =
     screen.queryByTitle('Open camera in new window') ?? screen.queryByTitle('Open camera overlay');
   expect(el).not.toBeNull();

+ 155 - 13
frontend/src/__tests__/pages/QueuePage.test.tsx

@@ -110,6 +110,32 @@ const mockPrinters = [
   },
 ];
 
+const HISTORY_STATUSES = ['completed', 'failed', 'skipped', 'cancelled'];
+
+type MockItem = { id: number; status: string; completed_at?: string | null; created_at?: string | null };
+
+// Serves one list of items the way the backend splits it: the active list on
+// /queue/ (by its status filter) and the paged, newest-first history on
+// /queue/history.
+function serveQueue(items: MockItem[]) {
+  return [
+    http.get('/api/v1/queue/', ({ request }) => {
+      const status = new URL(request.url).searchParams.get('status');
+      const wanted = status ? status.split(',') : null;
+      return HttpResponse.json(wanted ? items.filter((i) => wanted.includes(i.status)) : items);
+    }),
+    http.get('/api/v1/queue/history', ({ request }) => {
+      const params = new URL(request.url).searchParams;
+      const limit = Number(params.get('limit') ?? 50);
+      const when = (i: MockItem) => Date.parse(i.completed_at || i.created_at || '') || 0;
+      const history = items
+        .filter((i) => HISTORY_STATUSES.includes(i.status))
+        .sort((a, b) => when(b) - when(a));
+      return HttpResponse.json({ items: history.slice(0, limit), total: history.length, locations: [] });
+    }),
+  ];
+}
+
 describe('QueuePage', () => {
   beforeEach(() => {
     // Mock localStorage.getItem to return expected defaults for queue page
@@ -121,9 +147,7 @@ describe('QueuePage', () => {
 
     // Setup MSW handlers for this test
     server.use(
-      http.get('/api/v1/queue/', () => {
-        return HttpResponse.json(mockQueueItems);
-      }),
+      ...serveQueue(mockQueueItems),
       http.get('/api/v1/printers/', () => {
         return HttpResponse.json(mockPrinters);
       }),
@@ -1427,11 +1451,7 @@ describe('QueuePage', () => {
     }));
 
     beforeEach(() => {
-      server.use(
-        http.get('/api/v1/queue/', () => {
-          return HttpResponse.json(manyHistory);
-        })
-      );
+      server.use(...serveQueue(manyHistory));
     });
 
     it('caps the History list at one page and reveals the rest on Show more', async () => {
@@ -1459,11 +1479,7 @@ describe('QueuePage', () => {
 
   describe('empty state', () => {
     it('shows empty state when no queue items', async () => {
-      server.use(
-        http.get('/api/v1/queue/', () => {
-          return HttpResponse.json([]);
-        })
-      );
+      server.use(...serveQueue([]));
 
       render(<QueuePage />);
 
@@ -2037,6 +2053,132 @@ describe('QueuePage', () => {
       expect('confirm_outcome' in captured.body!).toBe(false);
     });
   });
+
+  // The page used to load the whole queue, history included, every five
+  // seconds; on a farm that stalled the server for over a minute.
+  describe('history from the server', () => {
+    it('asks /queue/ for active work only', async () => {
+      const statuses: (string | null)[] = [];
+      server.use(
+        http.get('/api/v1/queue/', ({ request }) => {
+          statuses.push(new URL(request.url).searchParams.get('status'));
+          return HttpResponse.json([mockQueueItems[0]]);
+        }),
+      );
+      render(<QueuePage />);
+
+      await screen.findByText('Test Print 1');
+      expect(statuses.length).toBeGreaterThan(0);
+      expect(statuses.every((s) => s === 'pending,printing,skipped')).toBe(true);
+    });
+
+    it('does not ask /queue/ at all while a finished status is selected', async () => {
+      const user = userEvent.setup();
+      const activeCalls: string[] = [];
+      server.use(
+        http.get('/api/v1/queue/', ({ request }) => {
+          activeCalls.push(new URL(request.url).searchParams.get('status') ?? '');
+          return HttpResponse.json([]);
+        }),
+      );
+      render(<QueuePage />);
+      await waitFor(() => expect(activeCalls.length).toBeGreaterThan(0));
+
+      await user.selectOptions(screen.getByDisplayValue('All Status'), 'completed');
+      activeCalls.length = 0;
+      await user.click(await screen.findByRole('button', { name: /^History/ }));
+
+      expect(await screen.findByText('Completed Print')).toBeInTheDocument();
+      expect(activeCalls).toEqual([]);
+    });
+
+    it('sends the sort to the server', async () => {
+      const user = userEvent.setup();
+      const requests: URLSearchParams[] = [];
+      server.use(
+        http.get('/api/v1/queue/history', ({ request }) => {
+          requests.push(new URL(request.url).searchParams);
+          return HttpResponse.json({ items: [mockQueueItems[2]], total: 1, locations: [] });
+        }),
+      );
+      render(<QueuePage />);
+      await user.click(await screen.findByRole('button', { name: /^History/ }));
+      await screen.findByText('Completed Print');
+
+      await user.selectOptions(screen.getByDisplayValue('Sort by Date'), 'name');
+      await waitFor(() => expect(requests.at(-1)?.get('sort_by')).toBe('name'));
+      expect(requests.at(-1)?.get('reverse')).toBeNull();
+
+      await user.click(screen.getByTitle('Descending (newest first)'));
+      await waitFor(() => expect(requests.at(-1)?.get('reverse')).toBe('true'));
+    });
+
+    it('offers the target locations found only in history', async () => {
+      server.use(
+        http.get('/api/v1/queue/history', () =>
+          HttpResponse.json({ items: [mockQueueItems[2]], total: 1, locations: ['Annex'] }),
+        ),
+      );
+      render(<QueuePage />);
+
+      expect(await screen.findByRole('option', { name: 'Annex' })).toBeInTheDocument();
+    });
+
+    it('counts every run of a batch, including those past the page', async () => {
+      const user = userEvent.setup();
+      const run = (id: number, status: string) => ({
+        ...mockQueueItems[2],
+        id,
+        status,
+        batch_id: 7,
+        batch_name: 'Order 7',
+      });
+      server.use(
+        http.get('/api/v1/queue/history', () =>
+          // One row on the page; the second run of its batch rides along after it
+          HttpResponse.json({ items: [run(70, 'completed'), run(71, 'failed')], total: 1, locations: [] }),
+        ),
+      );
+      render(<QueuePage />);
+      await user.click(await screen.findByRole('button', { name: /^History/ }));
+
+      // The failed run is past the page, yet the batch row counts it
+      const batchRow = (await screen.findByText('Order 7')).closest('button') as HTMLElement;
+      const failedCount = batchRow.querySelector('span.text-red-700');
+      expect(failedCount?.textContent).toBe('1');
+    });
+
+    it('clears history with one request carrying the filters', async () => {
+      const user = userEvent.setup();
+      let clearUrl: URL | null = null;
+      let deletes = 0;
+      server.use(
+        http.post('/api/v1/queue/history/clear', ({ request }) => {
+          clearUrl = new URL(request.url);
+          return HttpResponse.json({ cleared: 4, kept: 1 });
+        }),
+        http.delete('/api/v1/queue/:id', () => {
+          deletes += 1;
+          return HttpResponse.json({ deleted: true });
+        }),
+      );
+      render(<QueuePage />);
+      await user.click(await screen.findByRole('button', { name: /^History/ }));
+      await user.selectOptions(await screen.findByDisplayValue('All Status'), 'completed');
+
+      await user.click(await screen.findByRole('button', { name: /clear history/i }));
+      await screen.findByText(/Are you sure you want to remove all 1 item/i);
+      // The modal's confirm button is the second "Clear History"
+      await user.click(screen.getAllByRole('button', { name: /clear history/i }).at(-1)!);
+
+      await waitFor(() => expect(clearUrl).not.toBeNull());
+      expect(clearUrl!.searchParams.get('status')).toBe('completed');
+      expect(deletes).toBe(0);
+      expect(
+        await screen.findByText('Cleared 4 history item(s) Kept 1 that batch orders still need.'),
+      ).toBeInTheDocument();
+    });
+  });
 });
 
 describe('QueuePage: jobs held by failed prints on every printer of their model', () => {

+ 20 - 9
frontend/src/__tests__/pages/QueuePageWiring.test.tsx

@@ -99,8 +99,18 @@ const capture = async (request: Request) => {
 
 const mutationCalls = () => calls.filter((c) => c.method !== 'GET');
 
-function useQueue(items: unknown[]) {
-  server.use(http.get('*/api/v1/queue/', () => HttpResponse.json(items)));
+// As the server splits them: skipped jobs are in both, the queue keeps them
+// for the "blocked by a previous failure" banner.
+const QUEUE_STATUSES = ['pending', 'printing', 'skipped'];
+const HISTORY_STATUSES = ['completed', 'failed', 'skipped', 'cancelled'];
+
+/** The queue as the page loads it: active jobs from /queue/, finished ones as one history page. */
+function useQueue(items: { status: string }[]) {
+  const history = items.filter((item) => HISTORY_STATUSES.includes(item.status));
+  server.use(
+    http.get('*/api/v1/queue/', () => HttpResponse.json(items.filter((item) => QUEUE_STATUSES.includes(item.status)))),
+    http.get('*/api/v1/queue/history', () => HttpResponse.json({ items: history, total: history.length, locations: [] })),
+  );
 }
 
 beforeEach(() => {
@@ -110,9 +120,13 @@ beforeEach(() => {
     if (key === 'queue.viewMode') return 'list';
     return null;
   });
+  useQueue([pendingA, printing, completedA]);
   server.use(
-    http.get('*/api/v1/queue/', () => HttpResponse.json([pendingA, printing, completedA])),
     http.get('*/api/v1/printers/', () => HttpResponse.json(printers)),
+    http.post('*/api/v1/queue/history/clear', async ({ request }) => {
+      await capture(request);
+      return HttpResponse.json({ cleared: 2, kept: 0 });
+    }),
     http.delete('*/api/v1/queue/:id', async ({ request }) => {
       await capture(request);
       return HttpResponse.json({ message: 'ok', deleted: true });
@@ -246,7 +260,7 @@ describe('QueuePage wiring: row actions', () => {
     expect(mutationCalls()).toHaveLength(0);
   });
 
-  it('Clear History -> confirm deletes every history item, nothing else', async () => {
+  it('Clear History -> confirm sends one clear for the history, nothing else', async () => {
     useQueue([pendingA, completedA, completedB]);
     const user = userEvent.setup();
     render(<QueuePage />);
@@ -257,11 +271,8 @@ describe('QueuePage wiring: row actions', () => {
     const dialog = dialogOf(await screen.findByText(/remove all 2 item\(s\) from the history/));
     await user.click(within(dialog).getByRole('button', { name: 'Clear History' }));
 
-    await waitFor(() => expect(mutationCalls()).toHaveLength(2));
-    expect(mutationCalls().map((c) => `${c.method} ${c.path}`).sort()).toEqual([
-      'DELETE /api/v1/queue/31',
-      'DELETE /api/v1/queue/32',
-    ]);
+    await waitFor(() => expect(mutationCalls()).toHaveLength(1));
+    expect(mutationCalls()[0]).toMatchObject({ method: 'POST', path: '/api/v1/queue/history/clear' });
   });
 
   it('Start Print on a staged item -> POST /queue/{id}/start without skipping the filament check', async () => {

+ 9 - 1
frontend/src/__tests__/setup.ts

@@ -7,6 +7,7 @@ import '@testing-library/jest-dom';
 import { afterAll, afterEach, beforeAll, vi } from 'vitest';
 import { cleanup } from '@testing-library/react';
 import { server } from './mocks/server';
+import { batchesSettled } from '../api/batch';
 
 // Initialize i18n for tests (suppresses react-i18next warnings)
 import '../i18n';
@@ -19,8 +20,15 @@ beforeAll(() =>
     onUnhandledRequest: 'bypass',
   })
 );
-afterEach(() => {
+afterEach(async () => {
   cleanup();
+  // Let batched reads (api/batch.ts) a test started go out and be answered
+  // before its mocks are reset, so they can't land on the next test's. Capped
+  // so a handler a test left hanging can't stall the suite; skipped under fake
+  // timers, which would never fire the window.
+  if (!vi.isFakeTimers()) {
+    await Promise.race([batchesSettled(), new Promise((resolve) => setTimeout(resolve, 200))]);
+  }
   server.resetHandlers();
 });
 afterAll(() => server.close());

+ 133 - 0
frontend/src/api/batch.ts

@@ -0,0 +1,133 @@
+/**
+ * Coalesces per-id reads into one bulk request.
+ *
+ * A page that shows every printer asks for each printer's status, slot presets,
+ * plugs and so on, one request per printer. On a print farm that was hundreds
+ * of requests per page load, all queued behind each other on the server. A
+ * batched read collects the ids asked for within a few milliseconds and sends
+ * them as one request to a bulk endpoint, then hands each caller its own
+ * answer. Callers, their query keys, intervals and invalidations are unchanged.
+ *
+ * A window that ends up with a single id uses the single-id request, so one
+ * printer refreshed after an action, or a page that shows one printer, behaves
+ * exactly as it did.
+ */
+// Long enough to catch every card's interval refetch firing in the same
+// moment, short enough not to be noticed.
+const WINDOW_MS = 10;
+// Keeps the bulk request's URL short; ids beyond this go in another request.
+const MAX_BATCH = 200;
+
+// Windows still collecting ids or waiting for their requests; see batchesSettled
+const active = new Set<Promise<unknown>>();
+
+/**
+ * Resolves once every batch already started has sent its requests and had
+ * them answered. Tests wait on it between cases, so a request one case
+ * started doesn't land on the next case's mocks.
+ */
+export function batchesSettled(): Promise<void> {
+  return Promise.allSettled([...active]).then(() => undefined);
+}
+
+interface Waiter<T> {
+  resolve: (value: T) => void;
+  reject: (reason: unknown) => void;
+}
+
+/**
+ * @param single the single-id request, used for a window with one id, for an
+ *   id the bulk answer left out, and when the bulk request fails for any
+ *   reason but a refusal (401/403).
+ * @param many the bulk request; returns an answer for every id it knows.
+ */
+export function batched<T>(
+  single: (id: number) => Promise<T>,
+  many: (ids: number[]) => Promise<Map<number, T>>,
+): (id: number) => Promise<T> {
+  let pending = new Map<number, Waiter<T>[]>();
+  let timer: ReturnType<typeof setTimeout> | null = null;
+
+  const settleOne = (id: number, waiters: Waiter<T>[]) =>
+    single(id).then(
+      (value) => waiters.forEach((w) => w.resolve(value)),
+      (error) => waiters.forEach((w) => w.reject(error)),
+    );
+
+  const flush = (): Promise<unknown> => {
+    const batch = pending;
+    pending = new Map();
+    timer = null;
+    const ids = [...batch.keys()];
+    if (ids.length === 1) {
+      return settleOne(ids[0], batch.get(ids[0])!);
+    }
+    const requests: Promise<unknown>[] = [];
+    for (let i = 0; i < ids.length; i += MAX_BATCH) {
+      const chunk = ids.slice(i, i + MAX_BATCH);
+      requests.push(many(chunk).then(
+        (answers) => {
+          // An id the bulk answer leaves out (unknown, or outside the
+          // caller's scope) is asked for on its own, which gives exactly the
+          // single request's answer: its 404.
+          const followUps: Promise<unknown>[] = [];
+          for (const id of chunk) {
+            const waiters = batch.get(id)!;
+            if (answers.has(id)) {
+              const value = answers.get(id) as T;
+              waiters.forEach((w) => w.resolve(value));
+            } else {
+              followUps.push(settleOne(id, waiters));
+            }
+          }
+          return Promise.all(followUps);
+        },
+        (error) => {
+          // Not allowed to read these at all: the single requests would be
+          // refused the same way, so don't send them.
+          const status = (error as { status?: number } | null)?.status;
+          if (status === 401 || status === 403) {
+            for (const id of chunk) batch.get(id)!.forEach((w) => w.reject(error));
+            return undefined;
+          }
+          // Anything else (a backend without the bulk route, which answers
+          // 404, 405 or 422; a server error; a dropped connection): ask one by
+          // one, as before, so one printer the server can't answer for fails
+          // only its own card rather than every card on the page.
+          return Promise.all(chunk.map((id) => settleOne(id, batch.get(id)!)));
+        },
+      ));
+    }
+    return Promise.all(requests);
+  };
+
+  return (id: number) =>
+    new Promise<T>((resolve, reject) => {
+      const waiters = pending.get(id);
+      if (waiters) waiters.push({ resolve, reject });
+      else pending.set(id, [{ resolve, reject }]);
+      if (timer === null) {
+        let done: () => void = () => {};
+        const settled = new Promise<void>((resolve) => {
+          done = resolve;
+        });
+        active.add(settled);
+        timer = setTimeout(() => {
+          void flush().finally(() => {
+            active.delete(settled);
+            done();
+          });
+        }, WINDOW_MS);
+      }
+    });
+}
+
+/** A JSON object keyed by id (keys arrive as strings) as a Map. */
+export function byId<T>(record: Record<string, T>): Map<number, T> {
+  return new Map(Object.entries(record).map(([key, value]) => [Number(key), value]));
+}
+
+/** A list of rows carrying their own id as a Map. */
+export function byField<T>(rows: T[], field: (row: T) => number): Map<number, T> {
+  return new Map(rows.map((row) => [field(row), row]));
+}

+ 122 - 14
frontend/src/api/client.ts

@@ -1,5 +1,6 @@
 import type { KProfileWriteResult } from '../utils/kprofileWrite';
 import type { ArchivePlatesResponse, LibraryFilePlatesResponse } from '../types/plates';
+import { batched, byField, byId } from './batch';
 
 const API_BASE = '/api/v1';
 
@@ -4812,6 +4813,108 @@ export interface AuthStatus {
 }
 
 // API functions
+export interface QueueHistoryFilters {
+  printerId?: number | null;
+  status?: string;
+  location?: string;
+}
+
+export interface QueueHistoryParams extends QueueHistoryFilters {
+  sortBy?: 'date' | 'name' | 'printer';
+  // Flips the default order: newest first by date, A to Z by name or printer.
+  reverse?: boolean;
+  limit?: number;
+  offset?: number;
+}
+
+export interface QueueHistoryPage {
+  // The page in display order, then any other runs of the batches on it.
+  items: PrintQueueItem[];
+  total: number;
+  locations: string[];
+}
+
+function queueHistorySearch(params: QueueHistoryParams): URLSearchParams {
+  const search = new URLSearchParams();
+  if (params.printerId) search.set('printer_id', String(params.printerId));
+  if (params.status) search.set('status', params.status);
+  if (params.location) search.set('location', params.location);
+  if (params.sortBy) search.set('sort_by', params.sortBy);
+  if (params.reverse) search.set('reverse', 'true');
+  if (params.limit !== undefined) search.set('limit', String(params.limit));
+  if (params.offset) search.set('offset', String(params.offset));
+  return search;
+}
+
+// Per-printer reads that a page showing every printer makes once per printer,
+// coalesced into one bulk request per few milliseconds (see ./batch). Each
+// keeps its single-printer request for a lone id.
+const idsParam = (ids: number[]) => `ids=${ids.join(',')}`;
+
+const printerStatusBatch = batched(
+  (id) => request<PrinterStatus>(`/printers/${id}/status`),
+  async (ids) => byField(await request<PrinterStatus[]>(`/bulk/printer-statuses?${idsParam(ids)}`), (s) => s.id),
+);
+
+const slotPresetsBatch = batched(
+  (id) => request<Record<number, SlotPresetMapping>>(`/printers/${id}/slot-presets`),
+  async (ids) => byId(await request<Record<string, Record<number, SlotPresetMapping>>>(`/bulk/slot-presets?${idsParam(ids)}`)),
+);
+
+const amsLabelsBatch = batched(
+  (id) => request<Record<number, string>>(`/printers/${id}/ams-labels`),
+  async (ids) => byId(await request<Record<string, Record<number, string>>>(`/bulk/ams-labels?${idsParam(ids)}`)),
+);
+
+type PrinterCardPlugs = { plug: SmartPlug | null; scripts: SmartPlug[] };
+const cardPlugs = async (ids: number[]) =>
+  byId(await request<Record<string, PrinterCardPlugs>>(`/bulk/card-plugs?${idsParam(ids)}`));
+
+const smartPlugBatch = batched(
+  (id) => request<SmartPlug | null>(`/smart-plugs/by-printer/${id}`),
+  async (ids) => new Map([...(await cardPlugs(ids))].map(([id, plugs]) => [id, plugs.plug])),
+);
+
+const scriptPlugsBatch = batched(
+  (id) => request<SmartPlug[]>(`/smart-plugs/by-printer/${id}/scripts`),
+  async (ids) => new Map([...(await cardPlugs(ids))].map(([id, plugs]) => [id, plugs.scripts])),
+);
+
+const haSensorReadingsBatch = batched(
+  (id) => request<PrinterHASensorReading[]>(`/ha-sensors/by-printer/${id}/readings`),
+  async (ids) => byId(await request<Record<string, PrinterHASensorReading[]>>(`/bulk/ha-sensor-readings?${idsParam(ids)}`)),
+);
+
+const firmwareUpdateBatch = batched(
+  (id) => request<FirmwareUpdateInfo>(`/firmware/updates/${id}`),
+  async (ids) =>
+    byField(await request<FirmwareUpdateInfo[]>(`/bulk/firmware-updates?${idsParam(ids)}`), (u) => u.printer_id),
+);
+
+// One batcher per status asked for: the bulk route takes a single status list.
+const printerQueueBatches = new Map<string, (id: number) => Promise<PrintQueueItem[]>>();
+const printerQueueBatch = (status: string) => {
+  let batch = printerQueueBatches.get(status);
+  if (!batch) {
+    batch = batched(
+      (id) => request<PrintQueueItem[]>(`/queue/?printer_id=${id}&status=${encodeURIComponent(status)}`),
+      async (ids) =>
+        byId(
+          await request<Record<string, PrintQueueItem[]>>(
+            `/bulk/printer-queues?${idsParam(ids)}&status=${encodeURIComponent(status)}`,
+          ),
+        ),
+    );
+    printerQueueBatches.set(status, batch);
+  }
+  return batch;
+};
+
+const foldersByArchiveBatch = batched(
+  (id) => request<LibraryFolder[]>(`/library/folders/by-archive/${id}`),
+  async (ids) => byId(await request<Record<string, LibraryFolder[]>>(`/bulk/archive-folders?${idsParam(ids)}`)),
+);
+
 export const api = {
   // Overlay branding
   getOverlayLogo: async (token: string | null, signal?: AbortSignal): Promise<Blob | null> => {
@@ -5114,8 +5217,7 @@ export const api = {
       method: 'POST',
       body: JSON.stringify({ printer_ids: printerIds, location }),
     }),
-  getPrinterStatus: (id: number) =>
-    request<PrinterStatus>(`/printers/${id}/status`),
+  getPrinterStatus: (id: number) => printerStatusBatch(id),
   refreshPrinterStatus: (id: number) =>
     request<{ status: string }>(`/printers/${id}/refresh-status`, {
       method: 'POST',
@@ -6373,8 +6475,8 @@ export const api = {
 
   // Smart Plugs
   getSmartPlugs: () => request<SmartPlug[]>('/smart-plugs/'),
-  getSmartPlugByPrinter: (printerId: number) => request<SmartPlug | null>(`/smart-plugs/by-printer/${printerId}`),
-  getScriptPlugsByPrinter: (printerId: number) => request<SmartPlug[]>(`/smart-plugs/by-printer/${printerId}/scripts`),
+  getSmartPlugByPrinter: (printerId: number) => smartPlugBatch(printerId),
+  getScriptPlugsByPrinter: (printerId: number) => scriptPlugsBatch(printerId),
   createSmartPlug: (data: SmartPlugCreate) =>
     request<SmartPlug>('/smart-plugs/', {
       method: 'POST',
@@ -6427,8 +6529,7 @@ export const api = {
   // Home Assistant sensors bound to a printer (#1148, #448)
   getHASensors: (printerId?: number) =>
     request<PrinterHASensor[]>(`/ha-sensors/${printerId ? `?printer_id=${printerId}` : ''}`),
-  getHASensorReadings: (printerId: number) =>
-    request<PrinterHASensorReading[]>(`/ha-sensors/by-printer/${printerId}/readings`),
+  getHASensorReadings: (printerId: number) => haSensorReadingsBatch(printerId),
   getBindableHAEntities: (search?: string) => {
     const params = search ? `?search=${encodeURIComponent(search)}` : '';
     return request<HADisplayEntity[]>(`/ha-sensors/entities${params}`);
@@ -6473,6 +6574,17 @@ export const api = {
     if (targetModel) params.set('target_model', targetModel);
     return request<PrintQueueItem[]>(`/queue/?${params}`);
   },
+  // History (completed / failed / skipped / cancelled), one page at a time.
+  getQueueHistory: (params: QueueHistoryParams) =>
+    request<QueueHistoryPage>(`/queue/history?${queueHistorySearch(params)}`),
+  // Removes the history matching the same filters, server-side.
+  clearQueueHistory: (params: QueueHistoryFilters) =>
+    request<{ cleared: number; kept: number }>(`/queue/history/clear?${queueHistorySearch(params)}`, {
+      method: 'POST',
+    }),
+  // One printer's queue as GET /queue/?printer_id= returns it: its own jobs
+  // plus the "Any <model>" jobs for its model. Batched across printer cards.
+  getPrinterQueue: (printerId: number, status: string) => printerQueueBatch(status)(printerId),
   addToQueue: (data: PrintQueueItemCreate) =>
     request<PrintQueueItem>('/queue/', {
       method: 'POST',
@@ -6654,16 +6766,14 @@ export const api = {
     }),
 
   // Slot Preset Mappings
-  getSlotPresets: (printerId: number) =>
-    request<Record<number, SlotPresetMapping>>(`/printers/${printerId}/slot-presets`),
+  getSlotPresets: (printerId: number) => slotPresetsBatch(printerId),
   saveSlotPreset: (printerId: number, amsId: number, trayId: number, presetId: string, presetName: string, presetSource = 'cloud', trayInfoIdx?: string) =>
     request<SlotPresetMapping>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}?preset_id=${encodeURIComponent(presetId)}&preset_name=${encodeURIComponent(presetName)}&preset_source=${encodeURIComponent(presetSource)}${trayInfoIdx ? `&tray_info_idx=${encodeURIComponent(trayInfoIdx)}` : ''}`, {
       method: 'PUT',
     }),
 
   // AMS Labels (user-defined friendly names)
-  getAmsLabels: (printerId: number) =>
-    request<Record<number, string>>(`/printers/${printerId}/ams-labels`),
+  getAmsLabels: (printerId: number) => amsLabelsBatch(printerId),
   saveAmsLabel: (printerId: number, amsId: number, label: string, amsSerial = '') =>
     request<{ ams_id: number; label: string }>(
       `/printers/${printerId}/ams-labels/${amsId}`,
@@ -7670,8 +7780,7 @@ export const api = {
     }),
   getLibraryFoldersByProject: (projectId: number) =>
     request<LibraryFolder[]>(`/library/folders/by-project/${projectId}`),
-  getLibraryFoldersByArchive: (archiveId: number) =>
-    request<LibraryFolder[]>(`/library/folders/by-archive/${archiveId}`),
+  getLibraryFoldersByArchive: (archiveId: number) => foldersByArchiveBatch(archiveId),
 
   getLibraryFiles: (
     folderId?: number | null,
@@ -9009,8 +9118,7 @@ export const firmwareApi = {
   checkUpdates: () =>
     request<{ updates: FirmwareUpdateInfo[]; updates_available: number }>('/firmware/updates'),
 
-  checkPrinterUpdate: (printerId: number) =>
-    request<FirmwareUpdateInfo>(`/firmware/updates/${printerId}`),
+  checkPrinterUpdate: (printerId: number) => firmwareUpdateBatch(printerId),
 
   prepareUpload: (printerId: number, version?: string) =>
     request<FirmwareUploadPrepare>(

+ 3 - 1
frontend/src/components/PrinterQueueWidget.tsx

@@ -20,7 +20,9 @@ export function PrinterQueueWidget({ printerId, printerModel, loadedFilamentType
   const { t } = useTranslation();
   const { data: queue } = useQuery({
     queryKey: ['queue', printerId, 'pending', printerModel],
-    queryFn: () => api.getQueue(printerId, 'pending', printerModel || undefined),
+    // The printer's model is resolved on the server, as GET /queue/ does when
+    // no target_model is given; the key keeps it so a model change refetches.
+    queryFn: () => api.getPrinterQueue(printerId, 'pending'),
     refetchInterval: 30000,
   });
 

+ 2 - 2
frontend/src/pages/PrintersPage.tsx

@@ -2562,7 +2562,7 @@ function PrinterCard({
   // Fetch queue count for this printer
   const { data: queueItems } = useQuery({
     queryKey: ['queue', printer.id, 'pending'],
-    queryFn: () => api.getQueue(printer.id, 'pending'),
+    queryFn: () => api.getPrinterQueue(printer.id, 'pending'),
   });
   // Filter queue items by filament compatibility (same logic as PrinterQueueWidget)
   // so the badge only shows on printers that can actually run the queued jobs.
@@ -2575,7 +2575,7 @@ function PrinterCard({
   // Fetch currently printing queue item to show who started it (Issue #206)
   const { data: printingQueueItems } = useQuery({
     queryKey: ['queue', printer.id, 'printing'],
-    queryFn: () => api.getQueue(printer.id, 'printing'),
+    queryFn: () => api.getPrinterQueue(printer.id, 'printing'),
     enabled: status?.state === 'RUNNING',
   });
 

+ 65 - 50
frontend/src/pages/QueuePage.tsx

@@ -1,7 +1,7 @@
 import { useState, useMemo, useEffect, useCallback } from 'react';
 import { bulkUpdateToast } from '../utils/queueBulkUpdate';
 import { useTranslation } from 'react-i18next';
-import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
+import { keepPreviousData, useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
 import { Link } from 'react-router-dom';
 import {
   DndContext,
@@ -1496,7 +1496,10 @@ type HistoryRow =
   | { kind: 'batch'; batchId: number; batchName: string; items: PrintQueueItem[] };
 
 interface HistorySectionProps {
+  // The loaded page, then any other runs of its batches (see getQueueHistory)
   items: PrintQueueItem[];
+  // Matching history in all, loaded or not
+  total: number;
   visibleCount: number;
   onShowMore: () => void;
   sortBy: 'date' | 'name' | 'printer';
@@ -1517,6 +1520,7 @@ interface HistorySectionProps {
 
 function HistorySection({
   items,
+  total,
   visibleCount,
   onShowMore,
   sortBy,
@@ -1569,7 +1573,7 @@ function HistorySection({
         <h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
           {t('queue.sections.history')}
           <span className="text-xs sm:text-sm font-normal text-bambu-gray">
-            ({t('queue.itemCount', { count: items.length })})
+            ({t('queue.itemCount', { count: total })})
           </span>
         </h2>
         <div className="flex items-center gap-2">
@@ -1695,15 +1699,15 @@ function HistorySection({
           );
         })}
       </div>
-      {items.length > visibleCount && (
+      {total > visibleCount && (
         <div className="mt-4 flex flex-col items-center gap-2">
           <Button variant="secondary" size="sm" onClick={onShowMore}>
             {t('queue.history.showMore')}
           </Button>
           <span className="text-xs text-bambu-gray">
             {t('queue.history.showingCount', {
-              shown: Math.min(visibleCount, items.length),
-              total: items.length,
+              shown: Math.min(visibleCount, total),
+              total,
             })}
           </span>
         </div>
@@ -1713,6 +1717,9 @@ function HistorySection({
 }
 
 const HISTORY_PAGE_SIZE = 50;
+// The statuses the Queue tab works with; the rest is history.
+const ACTIVE_STATUSES = ['pending', 'printing', 'skipped'];
+const EMPTY_QUEUE: PrintQueueItem[] = [];
 
 export function QueuePage() {
   const { t } = useTranslation();
@@ -1863,11 +1870,46 @@ export function QueuePage() {
   });
   const activeBatchCount = activeBatches?.length ?? 0;
 
-  const { data: queue, isLoading } = useQuery({
-    queryKey: ['queue', filterPrinter, filterStatus],
-    queryFn: () => api.getQueue(filterPrinter || undefined, filterStatus || undefined),
+  // Only what is still in play: pending and printing, plus skipped for the
+  // Resume-after-failure banner. History has its own paged query below. This
+  // used to be the whole queue, history included, every five seconds; on a
+  // farm that was thousands of rows and stalled the server.
+  const activeStatuses = filterStatus
+    ? (ACTIVE_STATUSES.includes(filterStatus) ? filterStatus : null)
+    : ACTIVE_STATUSES.join(',');
+  const { data: activeQueue, isLoading } = useQuery({
+    queryKey: ['queue', filterPrinter, activeStatuses],
+    queryFn: () => api.getQueue(filterPrinter || undefined, activeStatuses ?? undefined),
     refetchInterval: 5000,
+    // A finished status selected in the filter leaves nothing active to show
+    enabled: activeStatuses !== null,
   });
+  const queue = activeStatuses === null ? EMPTY_QUEUE : activeQueue;
+
+  // One page of history at a time, filtered and sorted on the server.
+  // "Show more" asks for a longer first page rather than appending, so a
+  // batch row always sees the rest of its runs.
+  const historyFilters = {
+    printerId: filterPrinter,
+    status: filterStatus || undefined,
+    location: filterLocation || undefined,
+  };
+  const { data: historyPage, isLoading: historyLoading } = useQuery({
+    queryKey: ['queue', 'history', historyFilters, historySortBy, historySortAsc, historyVisibleCount],
+    queryFn: () =>
+      api.getQueueHistory({
+        ...historyFilters,
+        sortBy: historySortBy,
+        reverse: historySortAsc,
+        limit: historyVisibleCount,
+      }),
+    // Nothing pushes history changes, so it is polled: briskly while the tab
+    // shows one page, slowly once it is expanded (a long page is what used to
+    // stall the server) or when only the badge and stats need the total.
+    refetchInterval: activeTab === 'history' && historyVisibleCount <= HISTORY_PAGE_SIZE ? 5000 : 30000,
+    placeholderData: keepPreviousData,
+  });
+  const historyTotal = historyPage?.total ?? 0;
 
   const { data: printers } = useQuery({
     queryKey: ['printers'],
@@ -1973,21 +2015,10 @@ export function QueuePage() {
   });
 
   const clearHistoryMutation = useMutation({
-    mutationFn: async () => {
-      const historyItems = queue?.filter(i =>
-        ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)
-      ) || [];
-      let cleared = 0;
-      let kept = 0;
-      for (const item of historyItems) {
-        const result = await api.removeFromQueue(item.id);
-        // A row a batch order still needs is kept rather than deleted, so the
-        // count has to come from what the backend actually did (#2960).
-        if (result.deleted === false) kept += 1;
-        else cleared += 1;
-      }
-      return { cleared, kept };
-    },
+    // One request for the history under the current filters. A row a batch
+    // order still needs is kept rather than deleted, so the counts come from
+    // what the backend actually did (#2960).
+    mutationFn: () => api.clearQueueHistory(historyFilters),
     onSuccess: ({ cleared, kept }) => {
       queryClient.invalidateQueries({ queryKey: ['queue'] });
       queryClient.invalidateQueries({ queryKey: ['batches'] });
@@ -2091,8 +2122,9 @@ export function QueuePage() {
     queue?.forEach(item => {
       if (item.target_location) locations.add(item.target_location);
     });
+    historyPage?.locations.forEach(location => locations.add(location));
     return Array.from(locations).sort();
-  }, [printers, queue]);
+  }, [printers, queue, historyPage?.locations]);
 
   // Helper to check if a queue item matches the location filter
   const matchesLocationFilter = useCallback((item: PrintQueueItem): boolean => {
@@ -2305,26 +2337,8 @@ export function QueuePage() {
     return map;
   }, [activePrinterIds, printerStatusQueries]);
 
-  const historyItems = useMemo(() => {
-    let items = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];
-    if (filterLocation) {
-      items = items.filter(matchesLocationFilter);
-    }
-    return [...items].sort((a, b) => {
-      let cmp: number;
-      if (historySortBy === 'name') {
-        const aName = a.archive_name || a.library_file_name || '';
-        const bName = b.archive_name || b.library_file_name || '';
-        cmp = aName.localeCompare(bName);
-      } else if (historySortBy === 'printer') {
-        cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
-      } else {
-        // Default: by date - most recent first (desc) is the natural order
-        cmp = (parseUTCDate(b.completed_at || b.created_at)?.getTime() ?? 0) - (parseUTCDate(a.completed_at || a.created_at)?.getTime() ?? 0);
-      }
-      return historySortAsc ? -cmp : cmp;
-    });
-  }, [queue, historySortBy, historySortAsc, matchesLocationFilter, filterLocation]);
+  // Filtered and sorted by the server; see the history query above.
+  const historyItems = historyPage?.items ?? EMPTY_QUEUE;
 
   // Calculate total queue time
   const totalQueueTime = useMemo(() => {
@@ -2689,7 +2703,7 @@ export function QueuePage() {
         {([
           { id: 'queue' as const, label: t('queue.tabs.queue'), icon: Clock, count: pendingItems.length + activeItems.length },
           { id: 'batches' as const, label: t('queue.tabs.batches'), icon: Package, count: activeBatchCount },
-          { id: 'history' as const, label: t('queue.tabs.history'), icon: ListOrdered, count: historyItems.length },
+          { id: 'history' as const, label: t('queue.tabs.history'), icon: ListOrdered, count: historyTotal },
           { id: 'timeline' as const, label: t('queue.tabs.timeline'), icon: GanttChart, count: null as number | null },
           // Slicer Pipelines dashboard (#1425 PR C). Lives here instead of
           // its own sidebar entry so the Print Queue page is the single
@@ -2724,7 +2738,7 @@ export function QueuePage() {
         pendingCount={pendingItems.length}
         totalTime={totalQueueTime}
         totalWeight={totalWeight}
-        historyCount={historyItems.length}
+        historyCount={historyTotal}
         t={t}
       />}
 
@@ -2844,7 +2858,7 @@ export function QueuePage() {
 
         <div className="hidden sm:block flex-1" />
 
-        {activeTab === 'history' && historyItems.length > 0 && (
+        {activeTab === 'history' && historyTotal > 0 && (
           <Button
             className="w-full sm:w-auto"
             variant="secondary"
@@ -2911,9 +2925,9 @@ export function QueuePage() {
         <PipelineRunsView />
       ) : activeTab === 'batches' ? (
         <BatchOrdersView hasPermission={hasPermission} t={t} focusBatchId={focusBatchId} />
-      ) : isLoading ? (
+      ) : isLoading || (activeTab === 'history' && historyLoading) ? (
         <div className="text-center py-12 text-bambu-gray">{t('common.loading')}</div>
-      ) : queue?.length === 0 ? (
+      ) : queue?.length === 0 && !historyLoading && historyTotal === 0 ? (
         <Card className="p-12 text-center border-dashed">
           <Calendar className="w-16 h-16 text-bambu-gray mx-auto mb-4 opacity-50" />
           <h3 className="text-xl font-medium text-white mb-2">{t('queue.empty.title')}</h3>
@@ -2941,6 +2955,7 @@ export function QueuePage() {
       ) : activeTab === 'history' ? (
         <HistorySection
           items={historyItems}
+          total={historyTotal}
           visibleCount={historyVisibleCount}
           onShowMore={() => setHistoryVisibleCount((c) => c + HISTORY_PAGE_SIZE)}
           sortBy={historySortBy}
@@ -3335,7 +3350,7 @@ export function QueuePage() {
       {showClearHistoryConfirm && (
         <ConfirmModal
           title={t('queue.confirm.clearHistoryTitle')}
-          message={t('queue.confirm.clearHistoryMessage', { count: historyItems.length })}
+          message={t('queue.confirm.clearHistoryMessage', { count: historyTotal })}
           confirmText={t('queue.clearHistory')}
           variant="danger"
           onConfirm={() => {

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/AdditionalSection-DdPAuWaE.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/AmsUnitCard-BGvo_F3b.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/ArchivesPage-DN-L26xj.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/AreaChart-DURfIplq.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/AssignToAmsModal-Cq6w0JgX.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/AuthContext-DM3YxV_q.js


+ 1 - 1
static/assets/Button-CDViejCk.js → static/assets/Button-C_-d0X5W.js

@@ -1 +1 @@
-import{w as e}from"./client-DWQBE01c.js";var t=e();function n({variant:e=`primary`,size:n=`md`,className:r=``,children:i,...a}){return(0,t.jsx)(`button`,{className:`inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed ${{primary:`bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green`,secondary:`bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray`,danger:`bg-red-600 hover:bg-red-700 text-white focus:ring-red-500`,ghost:`bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white`}[e]} ${{sm:`px-3 py-1.5 text-sm gap-1.5 min-h-[44px] md:min-h-0`,md:`px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0`,lg:`px-6 py-3 text-base gap-2 min-h-[48px] md:min-h-0`}[n]} ${r}`,...a,children:i})}export{n as t};
+import{w as e}from"./client-DSw2TdH4.js";var t=e();function n({variant:e=`primary`,size:n=`md`,className:r=``,children:i,...a}){return(0,t.jsx)(`button`,{className:`inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed ${{primary:`bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green`,secondary:`bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray`,danger:`bg-red-600 hover:bg-red-700 text-white focus:ring-red-500`,ghost:`bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white`}[e]} ${{sm:`px-3 py-1.5 text-sm gap-1.5 min-h-[44px] md:min-h-0`,md:`px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0`,lg:`px-6 py-3 text-base gap-2 min-h-[48px] md:min-h-0`}[n]} ${r}`,...a,children:i})}export{n as t};

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/CamWallPage-Dc7zAekP.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/CameraDiagnoseModal-BydfnYfD.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/CameraPage-DXL0JpHU.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/CameraWall-DBrA6v1h.js


+ 1 - 1
static/assets/Card-DwWt21H0.js → static/assets/Card-BT_YWvIC.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-DWQBE01c.js";var r=e(t(),1),i=n(),a=(0,r.createContext)(`normal`);function o({density:e,children:t}){return(0,i.jsx)(a.Provider,{value:e,children:t})}function s({children:e,className:t=``,onClick:n,onContextMenu:r,...a}){return(0,i.jsx)(`div`,{className:`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${n?`cursor-pointer`:``} ${t}`,onClick:n,onContextMenu:r,...a,children:e})}function c({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`px-4 py-2.5`:`px-6 py-4`} border-b border-bambu-dark-tertiary ${t}`,children:e})}function l({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`p-4`:`p-6`} ${t}`,children:e})}export{c as i,l as n,o as r,s as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-DSw2TdH4.js";var r=e(t(),1),i=n(),a=(0,r.createContext)(`normal`);function o({density:e,children:t}){return(0,i.jsx)(a.Provider,{value:e,children:t})}function s({children:e,className:t=``,onClick:n,onContextMenu:r,...a}){return(0,i.jsx)(`div`,{className:`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${n?`cursor-pointer`:``} ${t}`,onClick:n,onContextMenu:r,...a,children:e})}function c({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`px-4 py-2.5`:`px-6 py-4`} border-b border-bambu-dark-tertiary ${t}`,children:e})}function l({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`p-4`:`p-6`} ${t}`,children:e})}export{c as i,l as n,o as r,s as t};

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/CartesianChart-DAoqaVbj.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/ColumnConfigModal-DHbBcF1q.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/ConfigureAmsSlotModal-qbxE23MO.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/ConfirmModal-lZYjzFL6.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/ConfirmOutcomeDialog-B-f6oDOv.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/ConnectAuthorizePage-RJGJ6Ng3.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/ConnectionDiagnostic-jD6ObGRm.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/ContextMenu-Ch1EMtUK.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/CopyButton-BPTbakjY.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/ErrorBarContext-EgF5FiX5.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/ExternalLinkPage-euN1onZW.js


+ 1 - 1
static/assets/FilamentSwatch-Dbs43U-F.js → static/assets/FilamentSwatch-CElzyuMD.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-DWQBE01c.js";import{n as r,r as i}from"./filamentSwatchHelpers-C3r5m07g.js";var a=e(t(),1),o=n();function s({rgba:e,extraColors:t,effectType:n,subtype:s,className:c=`w-5 h-5`,shape:l=`circle`,style:u,title:d,effectSize:f}){let p=(0,a.useMemo)(()=>i(t),[t]),m=(0,a.useMemo)(()=>r({effectSize:f,rgba:e,extraColors:t,effectType:n,subtype:s}),[f,e,t,n,s]),h=m.backgroundImage,g=m.backgroundSize,_=l===`circle`||l===`pill`?`rounded-full`:`rounded`,v=(()=>{if(!e)return;let t=e.replace(/^#/,``);return t.length>=8&&t.substring(6,8).toLowerCase()!==`ff`?`#${t.substring(0,8)}`:`#${t.substring(0,6)}`})(),y=d??(p.length>0?p.join(`, `):v);return(0,o.jsx)(`span`,{"data-testid":`filament-swatch`,className:`${c} ${_} border border-black/20 inline-block flex-shrink-0`,style:{backgroundImage:h,backgroundSize:g,...u},title:y})}export{s as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-DSw2TdH4.js";import{n as r,r as i}from"./filamentSwatchHelpers-C3r5m07g.js";var a=e(t(),1),o=n();function s({rgba:e,extraColors:t,effectType:n,subtype:s,className:c=`w-5 h-5`,shape:l=`circle`,style:u,title:d,effectSize:f}){let p=(0,a.useMemo)(()=>i(t),[t]),m=(0,a.useMemo)(()=>r({effectSize:f,rgba:e,extraColors:t,effectType:n,subtype:s}),[f,e,t,n,s]),h=m.backgroundImage,g=m.backgroundSize,_=l===`circle`||l===`pill`?`rounded-full`:`rounded`,v=(()=>{if(!e)return;let t=e.replace(/^#/,``);return t.length>=8&&t.substring(6,8).toLowerCase()!==`ff`?`#${t.substring(0,8)}`:`#${t.substring(0,6)}`})(),y=d??(p.length>0?p.join(`, `):v);return(0,o.jsx)(`span`,{"data-testid":`filament-swatch`,className:`${c} ${_} border border-black/20 inline-block flex-shrink-0`,style:{backgroundImage:h,backgroundSize:g,...u},title:y})}export{s as t};

Файловите разлики са ограничени, защото са твърде много
+ 0 - 1
static/assets/FileManagerPage-CN1v606g.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/FileUploadModal-Bj1b8vZP.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/FinancePage-Ckiak_8E.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/GCodeViewerPage-Dymps3eX.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/GcodeToolpathViewer-D7Hem9M3.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/GroupEditPage-DLjnxysJ.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/IconPicker-Bdnx5WSm.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/ImagePreviewModal-CxVgU3EM.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/InventoryPage-BZGzMItL.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/Layout-BE8fdF9U.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/LibraryTrashPage-DmFmO6Zj.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/LoginPage-DqpAbx6i.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/MaintenancePage-BKNjosyr.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/ModelSourcesPage-BNqWYg2M.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/NotificationsPage-BH7yCb55.js


+ 1 - 1
static/assets/NumberInput-Cf4OPY5C.js → static/assets/NumberInput-BhsGuNcX.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-DWQBE01c.js";var r=e(t(),1),i=n();function a({value:e,onChange:t,min:n,max:a,integer:o=!0,fallback:s,onBlur:c,...l}){let[u,d]=(0,r.useState)(null),f=e=>o?parseInt(e,10):parseFloat(e),p=e=>(n===void 0||e>=n)&&(a===void 0||e<=a),m=e=>Math.min(a??1/0,Math.max(n??-1/0,e));return(0,i.jsx)(`input`,{...l,type:`number`,min:n,max:a,value:u??e??``,onChange:n=>{let r=n.target.value;d(r);let i=f(r);!Number.isNaN(i)&&p(i)&&i!==e&&t(i)},onBlur:n=>{if(u!==null){let n=f(u),r=Number.isNaN(n)?s:n;r!==void 0&&m(r)!==e&&t(m(r)),d(null)}c?.(n)}})}export{a as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-DSw2TdH4.js";var r=e(t(),1),i=n();function a({value:e,onChange:t,min:n,max:a,integer:o=!0,fallback:s,onBlur:c,...l}){let[u,d]=(0,r.useState)(null),f=e=>o?parseInt(e,10):parseFloat(e),p=e=>(n===void 0||e>=n)&&(a===void 0||e<=a),m=e=>Math.min(a??1/0,Math.max(n??-1/0,e));return(0,i.jsx)(`input`,{...l,type:`number`,min:n,max:a,value:u??e??``,onChange:n=>{let r=n.target.value;d(r);let i=f(r);!Number.isNaN(i)&&p(i)&&i!==e&&t(i)},onBlur:n=>{if(u!==null){let n=f(u),r=Number.isNaN(n)?s:n;r!==void 0&&m(r)!==e&&t(m(r)),d(null)}c?.(n)}})}export{a as t};

+ 1 - 1
static/assets/OverlayFrame-DFtE4f7m.js → static/assets/OverlayFrame-CPrBopi9.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{a as t,k as n,w as r}from"./client-DWQBE01c.js";var i=e(n(),1);function a(e,n=null,r=0){let[a,o]=(0,i.useState)(null);return(0,i.useEffect)(()=>{if(!e)return;let i=new AbortController,a;return t.getOverlayLogo(n,i.signal).then(e=>{i.signal.aborted||(e?(a=URL.createObjectURL(e),o({url:a,token:n,revision:r})):o(null))}).catch(()=>{i.signal.aborted||o(null)}),()=>{i.abort(),a&&URL.revokeObjectURL(a)}},[e,n,r]),e&&a?.token===n&&a.revision===r?a.url:null}var o={logo:!1,logoRevision:0,from:``,to:``};function s(e){return/^#[0-9a-f]{6}$/i.test(e)}function c(e,t){return e&&t&&s(e)&&s(t)?`linear-gradient(to right, ${e}, ${t})`:void 0}function l(e){if(e)return{backgroundImage:e,backgroundClip:`text`,WebkitBackgroundClip:`text`,color:`transparent`,WebkitTextFillColor:`transparent`}}var u={landscape:{width:1920,height:1080},portrait:{width:1080,height:1920}},d=r();function f({layout:e,preview:t=!1,children:n}){let r=(0,i.useRef)(null),[a,o]=(0,i.useState)(0),{width:s,height:c}=u[e];(0,i.useLayoutEffect)(()=>{let e=r.current;if(!e)return;let t=()=>o(Math.min(e.clientWidth/s,e.clientHeight/c));if(t(),typeof ResizeObserver>`u`)return window.addEventListener(`resize`,t),()=>window.removeEventListener(`resize`,t);let n=new ResizeObserver(t);return n.observe(e),()=>n.disconnect()},[s,c]);let l={width:s,height:c,"--overlay-vmin":`10.8px`,"--overlay-height":`${c}px`,position:`absolute`,top:`50%`,left:`50%`,transform:`translate(-50%, -50%) scale(${a})`,transformOrigin:`center`};return(0,d.jsx)(`div`,{ref:r,className:t?`relative w-full overflow-hidden`:`fixed inset-0 overflow-hidden`,style:t?{aspectRatio:`${s} / ${c}`}:void 0,children:(0,d.jsx)(`div`,{style:l,children:n})})}export{c as a,s as i,u as n,l as o,o as r,a as s,f as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{a as t,k as n,w as r}from"./client-DSw2TdH4.js";var i=e(n(),1);function a(e,n=null,r=0){let[a,o]=(0,i.useState)(null);return(0,i.useEffect)(()=>{if(!e)return;let i=new AbortController,a;return t.getOverlayLogo(n,i.signal).then(e=>{i.signal.aborted||(e?(a=URL.createObjectURL(e),o({url:a,token:n,revision:r})):o(null))}).catch(()=>{i.signal.aborted||o(null)}),()=>{i.abort(),a&&URL.revokeObjectURL(a)}},[e,n,r]),e&&a?.token===n&&a.revision===r?a.url:null}var o={logo:!1,logoRevision:0,from:``,to:``};function s(e){return/^#[0-9a-f]{6}$/i.test(e)}function c(e,t){return e&&t&&s(e)&&s(t)?`linear-gradient(to right, ${e}, ${t})`:void 0}function l(e){if(e)return{backgroundImage:e,backgroundClip:`text`,WebkitBackgroundClip:`text`,color:`transparent`,WebkitTextFillColor:`transparent`}}var u={landscape:{width:1920,height:1080},portrait:{width:1080,height:1920}},d=r();function f({layout:e,preview:t=!1,children:n}){let r=(0,i.useRef)(null),[a,o]=(0,i.useState)(0),{width:s,height:c}=u[e];(0,i.useLayoutEffect)(()=>{let e=r.current;if(!e)return;let t=()=>o(Math.min(e.clientWidth/s,e.clientHeight/c));if(t(),typeof ResizeObserver>`u`)return window.addEventListener(`resize`,t),()=>window.removeEventListener(`resize`,t);let n=new ResizeObserver(t);return n.observe(e),()=>n.disconnect()},[s,c]);let l={width:s,height:c,"--overlay-vmin":`10.8px`,"--overlay-height":`${c}px`,position:`absolute`,top:`50%`,left:`50%`,transform:`translate(-50%, -50%) scale(${a})`,transformOrigin:`center`};return(0,d.jsx)(`div`,{ref:r,className:t?`relative w-full overflow-hidden`:`fixed inset-0 overflow-hidden`,style:t?{aspectRatio:`${s} / ${c}`}:void 0,children:(0,d.jsx)(`div`,{style:l,children:n})})}export{c as a,s as i,u as n,l as o,o as r,a as s,f as t};

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/PdfPreviewModal-CgIODxir.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/PhotoGalleryModal-Dy3RV0a3.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/PrintModal-DJVIzPGw.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/PrinterLocationsPage-D0SKHcm8.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/PrintersPage-CB4FC-Ph.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/ProfilesPage-C7nFKumA.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/ProjectDetailPage-DyI40PbB.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/ProjectsPage-BuB2O1SW.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/QueryClientProvider-B5vEWYBq.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/QueryClientProvider-CfzZA5MJ.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/QueuePage-D_6Y-y7F.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/QueuePage-Xyqf5-O-.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/ReferenceLine-CYMTRrT9.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/RichTextEditor-COtD6BDe.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/SettingsPage-CvhvM_iZ.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/SetupPage-M3LKrrCB.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/SliceModal-B-118oXv.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/SpoolBuddyAmsPage-C3v9i05Y.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/SpoolBuddyDashboard-CJnfrGIo.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-CNu9Lx3S.js


Файловите разлики са ограничени, защото са твърде много
+ 1 - 1
static/assets/SpoolBuddySettingsPage-B80Zf1Ne.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-B6PhrrO_.js


+ 1 - 1
static/assets/SpoolIcon-DAVcpMw0.js → static/assets/SpoolIcon-Ctt9uuSs.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-DWQBE01c.js";import{n as r}from"./filamentSwatchHelpers-C3r5m07g.js";import{t as i}from"./spoolPaint-Imx_KAjK.js";var a=e(t(),1),o=n();function s({spool:e,inset:t,size:n,border:i}){let s=n>=80?`card`:`preview`,c=(0,a.useMemo)(()=>r({effectSize:s,rgba:e.rgba,extraColors:e.extra_colors,effectType:e.effect_type,subtype:e.subtype}),[s,e.rgba,e.extra_colors,e.effect_type,e.subtype]),l=`${t*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-paint`,className:`absolute rounded-full box-border`,style:{top:l,left:l,right:l,bottom:l,...c,backgroundPosition:`center`,border:i}})}function c({inset:e}){let t=`${e*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-shade`,className:`absolute rounded-full`,style:{top:t,left:t,right:t,bottom:t,backgroundColor:`rgba(0, 0, 0, 0.15)`}})}function l({color:e,isEmpty:t,size:n=32,spool:r}){return t?(0,o.jsx)(`div`,{className:`rounded-full border-2 border-dashed border-zinc-500 flex items-center justify-center`,style:{width:n,height:n},children:(0,o.jsx)(`div`,{className:`w-2 h-2 rounded-full bg-zinc-600`})}):i(r)?(0,o.jsxs)(`div`,{className:`relative shrink-0`,style:{width:n,height:n},children:[(0,o.jsx)(s,{spool:r,size:n,inset:2/32,border:`${1.5*n/32}px solid rgba(255, 255, 255, 0.7)`}),(0,o.jsx)(c,{inset:5/32})]}):(0,o.jsxs)(`svg`,{width:n,height:n,viewBox:`0 0 32 32`,children:[(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`14`,fill:e,stroke:`white`,strokeWidth:`1.5`,strokeOpacity:`0.7`}),(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`11`,fill:e,style:{filter:`brightness(0.85)`}})]})}export{s as n,c as r,l as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-DSw2TdH4.js";import{n as r}from"./filamentSwatchHelpers-C3r5m07g.js";import{t as i}from"./spoolPaint-Imx_KAjK.js";var a=e(t(),1),o=n();function s({spool:e,inset:t,size:n,border:i}){let s=n>=80?`card`:`preview`,c=(0,a.useMemo)(()=>r({effectSize:s,rgba:e.rgba,extraColors:e.extra_colors,effectType:e.effect_type,subtype:e.subtype}),[s,e.rgba,e.extra_colors,e.effect_type,e.subtype]),l=`${t*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-paint`,className:`absolute rounded-full box-border`,style:{top:l,left:l,right:l,bottom:l,...c,backgroundPosition:`center`,border:i}})}function c({inset:e}){let t=`${e*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-shade`,className:`absolute rounded-full`,style:{top:t,left:t,right:t,bottom:t,backgroundColor:`rgba(0, 0, 0, 0.15)`}})}function l({color:e,isEmpty:t,size:n=32,spool:r}){return t?(0,o.jsx)(`div`,{className:`rounded-full border-2 border-dashed border-zinc-500 flex items-center justify-center`,style:{width:n,height:n},children:(0,o.jsx)(`div`,{className:`w-2 h-2 rounded-full bg-zinc-600`})}):i(r)?(0,o.jsxs)(`div`,{className:`relative shrink-0`,style:{width:n,height:n},children:[(0,o.jsx)(s,{spool:r,size:n,inset:2/32,border:`${1.5*n/32}px solid rgba(255, 255, 255, 0.7)`}),(0,o.jsx)(c,{inset:5/32})]}):(0,o.jsxs)(`svg`,{width:n,height:n,viewBox:`0 0 32 32`,children:[(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`14`,fill:e,stroke:`white`,strokeWidth:`1.5`,strokeOpacity:`0.7`}),(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`11`,fill:e,style:{filter:`brightness(0.85)`}})]})}export{s as n,c as r,l as t};

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/SpreadsheetPreviewModal-DOiHrhWo.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/StatsPage-BfkKD_sb.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/StreamOverlayPage-BPYmo-Mp.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/SystemHealthPanel-DKhr_xng.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/SystemInfoPage-CnRoIemM.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/ThemeContext-DGVIadPK.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/ToastContext-C3zxW_t3.js


Някои файлове не бяха показани, защото твърде много файлове са промени