Просмотр исходного кода

Batch per-printer reads into bulk requests

Every printer card fetched its own status, slot presets, AMS labels,
plugs, sensor readings, firmware and queue, and other pages read each
printer's status one by one. On a 59-printer farm the Printers page sent
about 550 requests on load, queued one behind the other on the server.

The API client now coalesces per-id reads arriving within 10 ms into one
request to the new /api/v1/bulk/ routes, each built on the same helper
as its single-id route. A lone id still uses the single route, an id
left out of a bulk answer is asked for on its own, and a missing bulk
route falls back to per-id requests. Callers, query keys, the 30 s
status poll and invalidations are unchanged. The Archives page's
per-card folder lookups are batched the same way.

A bulk request that fails for any reason but a 401/403 is retried per id,
so a printer the server cannot answer for fails only its own card.
maziggy 2 недель назад
Родитель
Сommit
f559d8461c
100 измененных файлов с 1372 добавлено и 66 удалено
  1. 4 0
      CHANGELOG.md
  2. 258 0
      backend/app/api/routes/bulk.py
  3. 4 0
      backend/app/api/routes/firmware.py
  4. 29 22
      backend/app/api/routes/ha_sensors.py
  5. 8 3
      backend/app/api/routes/library.py
  6. 14 1
      backend/app/api/routes/printers.py
  7. 14 1
      backend/app/api/routes/smart_plugs.py
  8. 2 0
      backend/app/main.py
  9. 29 0
      backend/app/utils/id_list.py
  10. 390 0
      backend/tests/integration/test_bulk_reads_api.py
  11. 28 0
      backend/tests/unit/test_id_list.py
  12. 198 0
      frontend/src/__tests__/api/batch.test.ts
  13. 4 1
      frontend/src/__tests__/components/PrintModal.test.tsx
  14. 51 0
      frontend/src/__tests__/mocks/handlers.ts
  15. 90 0
      frontend/src/__tests__/pages/PrintersPageBatchedReads.test.tsx
  16. 3 0
      frontend/src/__tests__/pages/PrintersPageCameraSplitButton.test.tsx
  17. 9 1
      frontend/src/__tests__/setup.ts
  18. 133 0
      frontend/src/api/batch.ts
  19. 81 14
      frontend/src/api/client.ts
  20. 3 1
      frontend/src/components/PrinterQueueWidget.tsx
  21. 2 2
      frontend/src/pages/PrintersPage.tsx
  22. 0 0
      static/assets/AdditionalSection-y4Mq_ft0.js
  23. 0 0
      static/assets/AmsUnitCard-BAu1vV4O.js
  24. 0 0
      static/assets/ArchivesPage-CFE8cTNw.js
  25. 0 0
      static/assets/AreaChart-Bcuq4XY7.js
  26. 0 0
      static/assets/AssignToAmsModal-FFVNChoG.js
  27. 1 1
      static/assets/Button-ghf17RHo.js
  28. 0 0
      static/assets/CamWallPage-C-31q19H.js
  29. 0 0
      static/assets/CameraDiagnoseModal-9rGoh6fM.js
  30. 0 0
      static/assets/CameraPage-BKvEtEgN.js
  31. 0 0
      static/assets/CameraWall-DmeqWVVx.js
  32. 1 1
      static/assets/Card-uyUFyUBv.js
  33. 0 0
      static/assets/CartesianChart-DIE-Nudm.js
  34. 0 0
      static/assets/ColumnConfigModal-D1WWbVt0.js
  35. 0 0
      static/assets/ConfigureAmsSlotModal-DTRlbTAZ.js
  36. 0 0
      static/assets/ConnectAuthorizePage-DpFCPks5.js
  37. 0 0
      static/assets/ContextMenu-CEpQaagC.js
  38. 0 0
      static/assets/CopyButton-CsuL8pZX.js
  39. 0 0
      static/assets/ErrorBarContext-nTFy6OPm.js
  40. 0 0
      static/assets/ExternalLinkPage-CWpmwmCe.js
  41. 0 1
      static/assets/FileManagerPage-XjEmYDzr.js
  42. 0 0
      static/assets/FileUploadModal-RJ22o4aD.js
  43. 0 0
      static/assets/FinancePage-L6j4WHLY.js
  44. 0 0
      static/assets/GCodeViewerPage-BYhtFFBy.js
  45. 0 0
      static/assets/GcodeToolpathViewer-Dq6h6Zu1.js
  46. 0 0
      static/assets/GroupEditPage-AvV68fB5.js
  47. 0 0
      static/assets/ImagePreviewModal-C7NLEkb2.js
  48. 0 0
      static/assets/InventoryPage-pvmbNoY3.js
  49. 0 0
      static/assets/LibraryTrashPage-CwVhXRUu.js
  50. 0 0
      static/assets/LoginPage-D33evUc_.js
  51. 0 0
      static/assets/MaintenancePage-wyegreW9.js
  52. 0 0
      static/assets/ModelSourcesPage-DKtB-H3B.js
  53. 0 0
      static/assets/NotificationsPage-D8Mcja1T.js
  54. 1 1
      static/assets/OverlayFrame-Cn9amV9D.js
  55. 0 1
      static/assets/PdfPreviewModal-CPdVF_yS.js
  56. 0 0
      static/assets/PhotoGalleryModal-CCeo5iZU.js
  57. 0 0
      static/assets/PrinterLocationsPage-B1Y3_-U6.js
  58. 0 0
      static/assets/PrintersPage-CNeAZFqh.js
  59. 0 0
      static/assets/ProfilesPage-zPlBpauM.js
  60. 0 0
      static/assets/ProjectDetailPage-e65TyJCf.js
  61. 0 0
      static/assets/ProjectsPage-BGIj8Dh9.js
  62. 0 0
      static/assets/QueryClientProvider-B8fIkVwV.js
  63. 0 0
      static/assets/QueuePage-Do-NGIw-.js
  64. 0 0
      static/assets/ReferenceLine-BT5rB_D-.js
  65. 0 0
      static/assets/RichTextEditor-_RF-XdZD.js
  66. 0 0
      static/assets/SettingsPage-Cag-eHtU.js
  67. 0 0
      static/assets/SetupPage-BQbtEMBv.js
  68. 0 0
      static/assets/SliceModal-BmitwUGi.js
  69. 0 0
      static/assets/SpoolBuddyAmsPage-B0IhsTwk.js
  70. 0 0
      static/assets/SpoolBuddyCalibrationPage-B_JP4is-.js
  71. 0 0
      static/assets/SpoolBuddyDashboard-p8DcFjuB.js
  72. 0 0
      static/assets/SpoolBuddyInventoryPage-DfsVih-5.js
  73. 1 1
      static/assets/SpoolBuddySettingsPage-C6bpYPaw.js
  74. 0 0
      static/assets/SpoolBuddyWriteTagPage-B8Bcc9dM.js
  75. 1 1
      static/assets/SpoolIcon-CbB6f_bm.js
  76. 0 0
      static/assets/SpreadsheetPreviewModal-DqpE4Zb6.js
  77. 0 0
      static/assets/StatsPage-BmkiKtKX.js
  78. 0 0
      static/assets/StreamOverlayPage-DISvxoA7.js
  79. 0 0
      static/assets/SystemInfoPage-D7HkEzl2.js
  80. 0 0
      static/assets/ToastContext-jNqS3U56.js
  81. 1 1
      static/assets/Toggle-BUzlZ7Db.js
  82. 0 1
      static/assets/arrow-down-CtKfgWNy.js
  83. 1 0
      static/assets/arrow-down-Da0Vs2Eu.js
  84. 0 1
      static/assets/arrow-left-DAszRgUL.js
  85. 1 0
      static/assets/arrow-left-DQwFLnnt.js
  86. 0 1
      static/assets/arrow-right-BM58VsWF.js
  87. 1 0
      static/assets/arrow-right-CoR5IvqA.js
  88. 1 0
      static/assets/arrow-up-C_Xe14wU.js
  89. 0 1
      static/assets/arrow-up-DOKMVj0r.js
  90. 1 1
      static/assets/arrow-up-down-B5f5Vy3a.js
  91. 1 1
      static/assets/box-D6Jqqxwt.js
  92. 1 1
      static/assets/briefcase-BF_Dw0ue.js
  93. 1 1
      static/assets/calendar-D3Py_3yt.js
  94. 1 0
      static/assets/check-C_ervVpn.js
  95. 0 1
      static/assets/check-DokQZ14y.js
  96. 1 1
      static/assets/chevrons-right-BPplI7dQ.js
  97. 0 0
      static/assets/client-B_W4DmE7.js
  98. 0 0
      static/assets/client-De3QMNnt.js
  99. 1 1
      static/assets/cloud-off-myBfyPNB.js
  100. 1 1
      static/assets/cog-luWCa2-I.js

+ 4 - 0
CHANGELOG.md

@@ -149,6 +149,10 @@ 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.

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

@@ -0,0 +1,258 @@
+"""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 PrinterCardPlugs, _card_script_plugs, _pick_main_plug
+from backend.app.core.auth import (
+    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.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=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,
+):
+    """``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():
+            by_printer[plug.printer_id].append(plug)
+    return {
+        i: PrinterCardPlugs(
+            plug=_pick_main_plug(plugs),
+            scripts=_card_script_plugs(plugs),
+        )
+        for i, plugs in by_printer.items()
+    }
+
+
+@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 | 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 | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
+):
+    """``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)

+ 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

+ 29 - 22
backend/app/api/routes/ha_sensors.py

@@ -6,7 +6,10 @@ from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled, RequirePrinterPermissionIfAuthEnabled
+from backend.app.core.auth import (
+    RequirePermissionIfAuthEnabled,
+    RequirePrinterPermissionIfAuthEnabled,
+)
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.printer import Printer
@@ -97,35 +100,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)

+ 8 - 3
backend/app/api/routes/library.py

@@ -1182,18 +1182,23 @@ async def get_folders_by_archive(
 ):
     """Get all folders linked to a specific archive, those the user may see (#3201)."""
     user, _ = auth_result
+    return (await _folders_by_archive(db, user, [archive_id]))[archive_id]
+
+
+async def _folders_by_archive(db: AsyncSession, user: User | None, archive_ids: list[int]) -> dict[int, list]:
+    """The folders linked to each archive that *user* may see (#3201), by archive id."""
     index = await _load_index(db, user)
     visible = visible_folder_ids(index, user)
     own_files = _own_files_filter(user)
     result = await db.execute(
         select(LibraryFolder, PrintArchive.print_name)
         .outerjoin(PrintArchive, LibraryFolder.archive_id == PrintArchive.id)
-        .where(LibraryFolder.archive_id == archive_id)
+        .where(LibraryFolder.archive_id.in_(archive_ids))
         .order_by(LibraryFolder.name)
     )
     rows = [row for row in result.all() if visible is None or row[0].id in visible]
 
-    folders = []
+    folders: dict[int, list] = {archive_id: [] for archive_id in archive_ids}
     for folder, archive_name 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.
@@ -1212,7 +1217,7 @@ 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(
+        folders[folder.archive_id].append(
             FolderResponse(
                 id=folder.id,
                 name=folder.name,

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

@@ -519,7 +519,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
@@ -2579,8 +2584,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,
@@ -3443,6 +3451,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

@@ -233,6 +233,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}
+    plug: SmartPlugResponse | None
+    # As GET /by-printer/{printer_id}/scripts
+    scripts: list[SmartPlugResponse]
+
+
 @router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None)
 async def get_smart_plug_by_printer(
     printer_id: int,
@@ -266,7 +275,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

@@ -26,6 +26,7 @@ from backend.app.api.routes import (
     archives,
     auth,
     bug_report,
+    bulk,
     camera,
     camwall,
     cloud,
@@ -10999,6 +11000,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)

+ 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

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

@@ -0,0 +1,390 @@
+"""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": []}
+
+
+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)] == []

+ 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

@@ -494,7 +494,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);

+ 51 - 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
   // ========================================================================

+ 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

@@ -96,6 +96,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();

+ 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]));
+}

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

@@ -1,4 +1,5 @@
 import type { ArchivePlatesResponse, LibraryFilePlatesResponse } from '../types/plates';
+import { batched, byField, byId } from './batch';
 
 const API_BASE = '/api/v1';
 
@@ -4805,6 +4806,75 @@ function queueHistorySearch(params: QueueHistoryParams): URLSearchParams {
   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> => {
@@ -5108,8 +5178,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',
@@ -6401,8 +6470,8 @@ export const api = {
   // Smart Plugs
   getSmartPlugs: () => request<SmartPlug[]>('/smart-plugs/'),
   getSmartPlug: (id: number) => request<SmartPlug>(`/smart-plugs/${id}`),
-  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',
@@ -6454,8 +6523,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}`);
@@ -6507,6 +6575,9 @@ export const api = {
     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),
   getQueueItem: (id: number) => request<PrintQueueItem>(`/queue/${id}`),
   addToQueue: (data: PrintQueueItemCreate) =>
     request<PrintQueueItem>('/queue/', {
@@ -6692,8 +6763,7 @@ export const api = {
     }),
 
   // Slot Preset Mappings
-  getSlotPresets: (printerId: number) =>
-    request<Record<number, SlotPresetMapping>>(`/printers/${printerId}/slot-presets`),
+  getSlotPresets: (printerId: number) => slotPresetsBatch(printerId),
   getSlotPreset: (printerId: number, amsId: number, trayId: number) =>
     request<SlotPresetMapping | null>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`),
   saveSlotPreset: (printerId: number, amsId: number, trayId: number, presetId: string, presetName: string, presetSource = 'cloud', trayInfoIdx?: string) =>
@@ -6706,8 +6776,7 @@ export const api = {
     }),
 
   // 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}`,
@@ -7815,8 +7884,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,
@@ -9255,8 +9323,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

@@ -2571,7 +2571,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.
@@ -2584,7 +2584,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',
   });
 

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/AdditionalSection-y4Mq_ft0.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/AmsUnitCard-BAu1vV4O.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ArchivesPage-CFE8cTNw.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/AreaChart-Bcuq4XY7.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/AssignToAmsModal-FFVNChoG.js


+ 1 - 1
static/assets/Button-e0aALvpZ.js → static/assets/Button-ghf17RHo.js

@@ -1 +1 @@
-import{C as e}from"./client-De3QMNnt.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{C as e}from"./client-B_W4DmE7.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-C-31q19H.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CameraDiagnoseModal-9rGoh6fM.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CameraPage-BKvEtEgN.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CameraWall-DmeqWVVx.js


+ 1 - 1
static/assets/Card-B_72lsm8.js → static/assets/Card-uyUFyUBv.js

@@ -1 +1 @@
-import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n}from"./client-De3QMNnt.js";var r=e(n(),1),i=t(),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"./chunk-aKtaBQYM.js";import{C as t,D as n}from"./client-B_W4DmE7.js";var r=e(n(),1),i=t(),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-DIE-Nudm.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ColumnConfigModal-D1WWbVt0.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ConfigureAmsSlotModal-DTRlbTAZ.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ConnectAuthorizePage-DpFCPks5.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ContextMenu-CEpQaagC.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CopyButton-CsuL8pZX.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ErrorBarContext-nTFy6OPm.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ExternalLinkPage-CWpmwmCe.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/FileManagerPage-XjEmYDzr.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/FileUploadModal-RJ22o4aD.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/FinancePage-L6j4WHLY.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/GCodeViewerPage-BYhtFFBy.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/GcodeToolpathViewer-Dq6h6Zu1.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/GroupEditPage-AvV68fB5.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ImagePreviewModal-C7NLEkb2.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/InventoryPage-pvmbNoY3.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/LibraryTrashPage-CwVhXRUu.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/LoginPage-D33evUc_.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/MaintenancePage-wyegreW9.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ModelSourcesPage-DKtB-H3B.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/NotificationsPage-D8Mcja1T.js


+ 1 - 1
static/assets/OverlayFrame-BFUz90OE.js → static/assets/OverlayFrame-Cn9amV9D.js

@@ -1 +1 @@
-import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r}from"./client-De3QMNnt.js";var i=e(n(),1);function a(e,t=null,n=0){let[a,o]=(0,i.useState)(null);return(0,i.useEffect)(()=>{if(!e)return;let i=new AbortController,a;return r.getOverlayLogo(t,i.signal).then(e=>{i.signal.aborted||(e?(a=URL.createObjectURL(e),o({url:a,token:t,revision:n})):o(null))}).catch(()=>{i.signal.aborted||o(null)}),()=>{i.abort(),a&&URL.revokeObjectURL(a)}},[e,t,n]),e&&a?.token===t&&a.revision===n?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=t();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"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r}from"./client-B_W4DmE7.js";var i=e(n(),1);function a(e,t=null,n=0){let[a,o]=(0,i.useState)(null);return(0,i.useEffect)(()=>{if(!e)return;let i=new AbortController,a;return r.getOverlayLogo(t,i.signal).then(e=>{i.signal.aborted||(e?(a=URL.createObjectURL(e),o({url:a,token:t,revision:n})):o(null))}).catch(()=>{i.signal.aborted||o(null)}),()=>{i.abort(),a&&URL.revokeObjectURL(a)}},[e,t,n]),e&&a?.token===t&&a.revision===n?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=t();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 - 1
static/assets/PdfPreviewModal-CPdVF_yS.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/PhotoGalleryModal-CCeo5iZU.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/PrinterLocationsPage-B1Y3_-U6.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/PrintersPage-CNeAZFqh.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ProfilesPage-zPlBpauM.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ProjectDetailPage-e65TyJCf.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ProjectsPage-BGIj8Dh9.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/QueryClientProvider-B8fIkVwV.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/QueuePage-Do-NGIw-.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ReferenceLine-BT5rB_D-.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/RichTextEditor-_RF-XdZD.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SettingsPage-Cag-eHtU.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SetupPage-BQbtEMBv.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SliceModal-BmitwUGi.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyAmsPage-B0IhsTwk.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-B_JP4is-.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyDashboard-p8DcFjuB.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-DfsVih-5.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
static/assets/SpoolBuddySettingsPage-C6bpYPaw.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-B8Bcc9dM.js


+ 1 - 1
static/assets/SpoolIcon-BYJN4ltL.js → static/assets/SpoolIcon-CbB6f_bm.js

@@ -1 +1 @@
-import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n}from"./client-De3QMNnt.js";import{n as r}from"./filamentSwatchHelpers-D3oXQSGk.js";import{t as i}from"./spoolPaint-CrvpHqdD.js";var a=e(n(),1),o=t();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"./chunk-aKtaBQYM.js";import{C as t,D as n}from"./client-B_W4DmE7.js";import{n as r}from"./filamentSwatchHelpers-D3oXQSGk.js";import{t as i}from"./spoolPaint-CrvpHqdD.js";var a=e(n(),1),o=t();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-DqpE4Zb6.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/StatsPage-BmkiKtKX.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/StreamOverlayPage-DISvxoA7.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SystemInfoPage-D7HkEzl2.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ToastContext-jNqS3U56.js


+ 1 - 1
static/assets/Toggle-imDq7B6T.js → static/assets/Toggle-BUzlZ7Db.js

@@ -1 +1 @@
-import{C as e}from"./client-De3QMNnt.js";var t=e();function n({checked:e,onChange:n,disabled:r}){return(0,t.jsx)(`button`,{type:`button`,role:`switch`,"aria-checked":e,disabled:r,onClick:t=>{t.preventDefault(),t.stopPropagation(),r||n(!e)},className:`relative inline-flex w-11 h-7 md:w-9 md:h-5 rounded-full transition-colors flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${r?`bg-bambu-dark-tertiary/50 cursor-not-allowed opacity-50`:e?`bg-bambu-green cursor-pointer`:`bg-bambu-dark-tertiary cursor-pointer hover:bg-bambu-dark-tertiary/80`}`,children:(0,t.jsx)(`span`,{className:`pointer-events-none absolute top-[3px] md:top-[2px] left-[3px] md:left-[2px] w-5 h-5 md:w-4 md:h-4 bg-white rounded-full shadow transition-transform duration-200 ease-in-out ${e?`translate-x-4`:`translate-x-0`}`})})}export{n as t};
+import{C as e}from"./client-B_W4DmE7.js";var t=e();function n({checked:e,onChange:n,disabled:r}){return(0,t.jsx)(`button`,{type:`button`,role:`switch`,"aria-checked":e,disabled:r,onClick:t=>{t.preventDefault(),t.stopPropagation(),r||n(!e)},className:`relative inline-flex w-11 h-7 md:w-9 md:h-5 rounded-full transition-colors flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${r?`bg-bambu-dark-tertiary/50 cursor-not-allowed opacity-50`:e?`bg-bambu-green cursor-pointer`:`bg-bambu-dark-tertiary cursor-pointer hover:bg-bambu-dark-tertiary/80`}`,children:(0,t.jsx)(`span`,{className:`pointer-events-none absolute top-[3px] md:top-[2px] left-[3px] md:left-[2px] w-5 h-5 md:w-4 md:h-4 bg-white rounded-full shadow transition-transform duration-200 ease-in-out ${e?`translate-x-4`:`translate-x-0`}`})})}export{n as t};

+ 0 - 1
static/assets/arrow-down-CtKfgWNy.js

@@ -1 +0,0 @@
-import{t as e}from"./createLucideIcon-BxhAes7f.js";var t=e(`arrow-down`,[[`path`,{d:`M12 5v14`,key:`s699le`}],[`path`,{d:`m19 12-7 7-7-7`,key:`1idqje`}]]);export{t};

+ 1 - 0
static/assets/arrow-down-Da0Vs2Eu.js

@@ -0,0 +1 @@
+import{t as e}from"./createLucideIcon-CtDmUDgU.js";var t=e(`arrow-down`,[[`path`,{d:`M12 5v14`,key:`s699le`}],[`path`,{d:`m19 12-7 7-7-7`,key:`1idqje`}]]);export{t};

+ 0 - 1
static/assets/arrow-left-DAszRgUL.js

@@ -1 +0,0 @@
-import{t as e}from"./createLucideIcon-BxhAes7f.js";var t=e(`arrow-left`,[[`path`,{d:`m12 19-7-7 7-7`,key:`1l729n`}],[`path`,{d:`M19 12H5`,key:`x3x0zl`}]]);export{t};

+ 1 - 0
static/assets/arrow-left-DQwFLnnt.js

@@ -0,0 +1 @@
+import{t as e}from"./createLucideIcon-CtDmUDgU.js";var t=e(`arrow-left`,[[`path`,{d:`m12 19-7-7 7-7`,key:`1l729n`}],[`path`,{d:`M19 12H5`,key:`x3x0zl`}]]);export{t};

+ 0 - 1
static/assets/arrow-right-BM58VsWF.js

@@ -1 +0,0 @@
-import{t as e}from"./createLucideIcon-BxhAes7f.js";var t=e(`arrow-right`,[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`m12 5 7 7-7 7`,key:`xquz4c`}]]);export{t};

+ 1 - 0
static/assets/arrow-right-CoR5IvqA.js

@@ -0,0 +1 @@
+import{t as e}from"./createLucideIcon-CtDmUDgU.js";var t=e(`arrow-right`,[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`m12 5 7 7-7 7`,key:`xquz4c`}]]);export{t};

+ 1 - 0
static/assets/arrow-up-C_Xe14wU.js

@@ -0,0 +1 @@
+import{t as e}from"./createLucideIcon-CtDmUDgU.js";var t=e(`arrow-up`,[[`path`,{d:`m5 12 7-7 7 7`,key:`hav0vg`}],[`path`,{d:`M12 19V5`,key:`x0mq9r`}]]);export{t};

+ 0 - 1
static/assets/arrow-up-DOKMVj0r.js

@@ -1 +0,0 @@
-import{t as e}from"./createLucideIcon-BxhAes7f.js";var t=e(`arrow-up`,[[`path`,{d:`m5 12 7-7 7 7`,key:`hav0vg`}],[`path`,{d:`M12 19V5`,key:`x0mq9r`}]]);export{t};

+ 1 - 1
static/assets/arrow-up-down-DeY5owo8.js → static/assets/arrow-up-down-B5f5Vy3a.js

@@ -1 +1 @@
-import{t as e}from"./createLucideIcon-BxhAes7f.js";var t=e(`arrow-up-down`,[[`path`,{d:`m21 16-4 4-4-4`,key:`f6ql7i`}],[`path`,{d:`M17 20V4`,key:`1ejh1v`}],[`path`,{d:`m3 8 4-4 4 4`,key:`11wl7u`}],[`path`,{d:`M7 4v16`,key:`1glfcx`}]]);export{t};
+import{t as e}from"./createLucideIcon-CtDmUDgU.js";var t=e(`arrow-up-down`,[[`path`,{d:`m21 16-4 4-4-4`,key:`f6ql7i`}],[`path`,{d:`M17 20V4`,key:`1ejh1v`}],[`path`,{d:`m3 8 4-4 4 4`,key:`11wl7u`}],[`path`,{d:`M7 4v16`,key:`1glfcx`}]]);export{t};

+ 1 - 1
static/assets/box-Bk6pltFB.js → static/assets/box-D6Jqqxwt.js

@@ -1 +1 @@
-import{t as e}from"./createLucideIcon-BxhAes7f.js";var t=e(`box`,[[`path`,{d:`M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z`,key:`hh9hay`}],[`path`,{d:`m3.3 7 8.7 5 8.7-5`,key:`g66t2b`}],[`path`,{d:`M12 22V12`,key:`d0xqtd`}]]);export{t};
+import{t as e}from"./createLucideIcon-CtDmUDgU.js";var t=e(`box`,[[`path`,{d:`M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z`,key:`hh9hay`}],[`path`,{d:`m3.3 7 8.7 5 8.7-5`,key:`g66t2b`}],[`path`,{d:`M12 22V12`,key:`d0xqtd`}]]);export{t};

+ 1 - 1
static/assets/briefcase-Cr5HjMJR.js → static/assets/briefcase-BF_Dw0ue.js

@@ -1 +1 @@
-import{t as e}from"./createLucideIcon-BxhAes7f.js";var t=e(`briefcase`,[[`path`,{d:`M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16`,key:`jecpp`}],[`rect`,{width:`20`,height:`14`,x:`2`,y:`6`,rx:`2`,key:`i6l2r4`}]]);export{t};
+import{t as e}from"./createLucideIcon-CtDmUDgU.js";var t=e(`briefcase`,[[`path`,{d:`M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16`,key:`jecpp`}],[`rect`,{width:`20`,height:`14`,x:`2`,y:`6`,rx:`2`,key:`i6l2r4`}]]);export{t};

+ 1 - 1
static/assets/calendar-3WeQihFV.js → static/assets/calendar-D3Py_3yt.js

@@ -1 +1 @@
-import{t as e}from"./createLucideIcon-BxhAes7f.js";var t=e(`calendar`,[[`path`,{d:`M8 2v4`,key:`1cmpym`}],[`path`,{d:`M16 2v4`,key:`4m81vk`}],[`rect`,{width:`18`,height:`18`,x:`3`,y:`4`,rx:`2`,key:`1hopcy`}],[`path`,{d:`M3 10h18`,key:`8toen8`}]]);export{t};
+import{t as e}from"./createLucideIcon-CtDmUDgU.js";var t=e(`calendar`,[[`path`,{d:`M8 2v4`,key:`1cmpym`}],[`path`,{d:`M16 2v4`,key:`4m81vk`}],[`rect`,{width:`18`,height:`18`,x:`3`,y:`4`,rx:`2`,key:`1hopcy`}],[`path`,{d:`M3 10h18`,key:`8toen8`}]]);export{t};

+ 1 - 0
static/assets/check-C_ervVpn.js

@@ -0,0 +1 @@
+import{t as e}from"./createLucideIcon-CtDmUDgU.js";var t=e(`check`,[[`path`,{d:`M20 6 9 17l-5-5`,key:`1gmf2c`}]]);export{t};

+ 0 - 1
static/assets/check-DokQZ14y.js

@@ -1 +0,0 @@
-import{t as e}from"./createLucideIcon-BxhAes7f.js";var t=e(`check`,[[`path`,{d:`M20 6 9 17l-5-5`,key:`1gmf2c`}]]);export{t};

+ 1 - 1
static/assets/chevrons-right-BfhMQWqH.js → static/assets/chevrons-right-BPplI7dQ.js

@@ -1 +1 @@
-import{t as e}from"./createLucideIcon-BxhAes7f.js";var t=e(`chevrons-left`,[[`path`,{d:`m11 17-5-5 5-5`,key:`13zhaf`}],[`path`,{d:`m18 17-5-5 5-5`,key:`h8a8et`}]]),n=e(`chevrons-right`,[[`path`,{d:`m6 17 5-5-5-5`,key:`xnjwq`}],[`path`,{d:`m13 17 5-5-5-5`,key:`17xmmf`}]]);export{t as n,n as t};
+import{t as e}from"./createLucideIcon-CtDmUDgU.js";var t=e(`chevrons-left`,[[`path`,{d:`m11 17-5-5 5-5`,key:`13zhaf`}],[`path`,{d:`m18 17-5-5 5-5`,key:`h8a8et`}]]),n=e(`chevrons-right`,[[`path`,{d:`m6 17 5-5-5-5`,key:`xnjwq`}],[`path`,{d:`m13 17 5-5-5-5`,key:`17xmmf`}]]);export{t as n,n as t};

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/client-B_W4DmE7.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/client-De3QMNnt.js


+ 1 - 1
static/assets/cloud-off-DxX8_NGS.js → static/assets/cloud-off-myBfyPNB.js

@@ -1 +1 @@
-import{t as e}from"./createLucideIcon-BxhAes7f.js";var t=e(`cloud-off`,[[`path`,{d:`m2 2 20 20`,key:`1ooewy`}],[`path`,{d:`M5.782 5.782A7 7 0 0 0 9 19h8.5a4.5 4.5 0 0 0 1.307-.193`,key:`yfwify`}],[`path`,{d:`M21.532 16.5A4.5 4.5 0 0 0 17.5 10h-1.79A7.008 7.008 0 0 0 10 5.07`,key:`jlfiyv`}]]);export{t};
+import{t as e}from"./createLucideIcon-CtDmUDgU.js";var t=e(`cloud-off`,[[`path`,{d:`m2 2 20 20`,key:`1ooewy`}],[`path`,{d:`M5.782 5.782A7 7 0 0 0 9 19h8.5a4.5 4.5 0 0 0 1.307-.193`,key:`yfwify`}],[`path`,{d:`M21.532 16.5A4.5 4.5 0 0 0 17.5 10h-1.79A7.008 7.008 0 0 0 10 5.07`,key:`jlfiyv`}]]);export{t};

+ 1 - 1
static/assets/cog-D0_K-Y1f.js → static/assets/cog-luWCa2-I.js

@@ -1 +1 @@
-import{t as e}from"./createLucideIcon-BxhAes7f.js";var t=e(`cog`,[[`path`,{d:`M11 10.27 7 3.34`,key:`16pf9h`}],[`path`,{d:`m11 13.73-4 6.93`,key:`794ttg`}],[`path`,{d:`M12 22v-2`,key:`1osdcq`}],[`path`,{d:`M12 2v2`,key:`tus03m`}],[`path`,{d:`M14 12h8`,key:`4f43i9`}],[`path`,{d:`m17 20.66-1-1.73`,key:`eq3orb`}],[`path`,{d:`m17 3.34-1 1.73`,key:`2wel8s`}],[`path`,{d:`M2 12h2`,key:`1t8f8n`}],[`path`,{d:`m20.66 17-1.73-1`,key:`sg0v6f`}],[`path`,{d:`m20.66 7-1.73 1`,key:`1ow05n`}],[`path`,{d:`m3.34 17 1.73-1`,key:`nuk764`}],[`path`,{d:`m3.34 7 1.73 1`,key:`1ulond`}],[`circle`,{cx:`12`,cy:`12`,r:`2`,key:`1c9p78`}],[`circle`,{cx:`12`,cy:`12`,r:`8`,key:`46899m`}]]);export{t};
+import{t as e}from"./createLucideIcon-CtDmUDgU.js";var t=e(`cog`,[[`path`,{d:`M11 10.27 7 3.34`,key:`16pf9h`}],[`path`,{d:`m11 13.73-4 6.93`,key:`794ttg`}],[`path`,{d:`M12 22v-2`,key:`1osdcq`}],[`path`,{d:`M12 2v2`,key:`tus03m`}],[`path`,{d:`M14 12h8`,key:`4f43i9`}],[`path`,{d:`m17 20.66-1-1.73`,key:`eq3orb`}],[`path`,{d:`m17 3.34-1 1.73`,key:`2wel8s`}],[`path`,{d:`M2 12h2`,key:`1t8f8n`}],[`path`,{d:`m20.66 17-1.73-1`,key:`sg0v6f`}],[`path`,{d:`m20.66 7-1.73 1`,key:`1ow05n`}],[`path`,{d:`m3.34 17 1.73-1`,key:`nuk764`}],[`path`,{d:`m3.34 7 1.73 1`,key:`1ulond`}],[`circle`,{cx:`12`,cy:`12`,r:`2`,key:`1c9p78`}],[`circle`,{cx:`12`,cy:`12`,r:`8`,key:`46899m`}]]);export{t};

Некоторые файлы не были показаны из-за большого количества измененных файлов