Explorar o código

Page the queue history on the server instead of polling the whole queue

The Queue page fetched every queue item, finished runs included, every
five seconds and split off the history in the browser. On a farm with a
few thousand finished runs the response was several megabytes, and
building it parsed each row's 3MF on the event loop (the 512-entry plate
cache thrashes at that size), holding up every other request for over a
minute.

The page now polls only pending, printing and skipped items, and loads
history from the new GET /queue/history: filtered, sorted and paged on
the server, with the other runs of each batch on the page. Clear History
is one POST /queue/history/clear instead of one DELETE per run, keeping
the #2960 rule through a helper shared with the single delete.

History pages have no practical size cap, so Show more can still reach the
whole history; an expanded page is polled every 30 s instead of 5 s.
Clearing reaches only rows the caller may both read and delete.
maziggy hai 2 semanas
pai
achega
12d42ec8d8
Modificáronse 100 ficheiros con 1052 adicións e 128 borrados
  1. 4 0
      CHANGELOG.md
  2. 291 35
      backend/app/api/routes/print_queue.py
  3. 20 0
      backend/app/schemas/print_queue.py
  4. 447 0
      backend/tests/integration/test_queue_history_api.py
  5. 1 0
      frontend/src/__tests__/mocks/handlers.ts
  6. 155 13
      frontend/src/__tests__/pages/QueuePage.test.tsx
  7. 41 0
      frontend/src/api/client.ts
  8. 65 50
      frontend/src/pages/QueuePage.tsx
  9. 0 0
      static/assets/AdditionalSection-2iJTQSha.js
  10. 0 0
      static/assets/AmsUnitCard-uz_uOAT6.js
  11. 0 0
      static/assets/ArchivesPage-CqMD3QO7.js
  12. 0 0
      static/assets/AreaChart-Nx2U3Ac_.js
  13. 0 0
      static/assets/AssignToAmsModal-XiKBRxai.js
  14. 1 1
      static/assets/Button-e0aALvpZ.js
  15. 0 0
      static/assets/CamWallPage-BNHUD_N0.js
  16. 0 0
      static/assets/CameraDiagnoseModal-D5EKxjHy.js
  17. 0 0
      static/assets/CameraPage-B4aCs6Og.js
  18. 0 0
      static/assets/CameraWall-DpUYc95q.js
  19. 1 1
      static/assets/Card-B_72lsm8.js
  20. 0 0
      static/assets/CartesianChart-cfP8kvqO.js
  21. 0 0
      static/assets/ColumnConfigModal-RmUrseYv.js
  22. 0 0
      static/assets/ConfigureAmsSlotModal-COaoNS8e.js
  23. 0 0
      static/assets/ConnectAuthorizePage-DUvZFvnL.js
  24. 0 0
      static/assets/ContextMenu-CW2O6tvS.js
  25. 0 0
      static/assets/CopyButton-CPoSjMIG.js
  26. 0 0
      static/assets/ErrorBarContext-D9KlYt2o.js
  27. 0 0
      static/assets/ExternalLinkPage-CckLLa3j.js
  28. 0 1
      static/assets/FileManagerPage-6gPI7pNx.js
  29. 0 0
      static/assets/FileUploadModal-D1FdeSh4.js
  30. 0 0
      static/assets/FinancePage-Bj40nSr3.js
  31. 0 0
      static/assets/GCodeViewerPage-DU-FIu2G.js
  32. 0 0
      static/assets/GcodeToolpathViewer-C5G3pm5Z.js
  33. 0 0
      static/assets/GroupEditPage-D0twEjqp.js
  34. 0 0
      static/assets/ImagePreviewModal-CYbgg1Uk.js
  35. 0 0
      static/assets/InventoryPage-Bl-JqlM1.js
  36. 0 0
      static/assets/LibraryTrashPage-CWixTgAQ.js
  37. 0 0
      static/assets/LoginPage-CVj61WhD.js
  38. 0 0
      static/assets/MaintenancePage-tuvgbi5i.js
  39. 0 0
      static/assets/ModelSourcesPage-zGBPC-gz.js
  40. 0 0
      static/assets/NotificationsPage-D9PMpI5P.js
  41. 1 1
      static/assets/OverlayFrame-BFUz90OE.js
  42. 0 1
      static/assets/PdfPreviewModal-C9teSdvB.js
  43. 0 0
      static/assets/PhotoGalleryModal-Dd4qxPfE.js
  44. 0 0
      static/assets/PrinterLocationsPage-DwsRt000.js
  45. 0 0
      static/assets/PrintersPage-ZBn6pjxx.js
  46. 0 0
      static/assets/ProfilesPage-0M2ZM_ix.js
  47. 0 0
      static/assets/ProjectDetailPage-C8DP7INb.js
  48. 0 0
      static/assets/ProjectsPage-DNpCQ6l0.js
  49. 0 0
      static/assets/QueryClientProvider-CsMjD-7g.js
  50. 0 0
      static/assets/QueryClientProvider-GUolQzem.js
  51. 0 0
      static/assets/QueuePage-CTHQ5vQ9.js
  52. 0 0
      static/assets/QueuePage-Kv8gGfNb.js
  53. 0 0
      static/assets/ReferenceLine-DWoUPJuy.js
  54. 0 0
      static/assets/RichTextEditor-DGXpd_AB.js
  55. 0 0
      static/assets/SettingsPage-DLstWXjY.js
  56. 0 0
      static/assets/SetupPage-DVPv2pi7.js
  57. 0 0
      static/assets/SliceModal-DIFcnLa8.js
  58. 0 0
      static/assets/SpoolBuddyAmsPage-BhwkPpAv.js
  59. 0 0
      static/assets/SpoolBuddyCalibrationPage-fV4Y4835.js
  60. 0 0
      static/assets/SpoolBuddyDashboard-DBFN6ewV.js
  61. 0 0
      static/assets/SpoolBuddyInventoryPage-BSfVND4V.js
  62. 1 1
      static/assets/SpoolBuddySettingsPage-CF1uzCiN.js
  63. 0 0
      static/assets/SpoolBuddyWriteTagPage-BGl5qJDW.js
  64. 1 1
      static/assets/SpoolIcon-BYJN4ltL.js
  65. 0 0
      static/assets/SpreadsheetPreviewModal-Cth51eSm.js
  66. 0 0
      static/assets/StatsPage-Gq3pWGaf.js
  67. 0 0
      static/assets/StreamOverlayPage-B8QGRXCM.js
  68. 0 0
      static/assets/SystemInfoPage-CfqUCfNj.js
  69. 0 0
      static/assets/ToastContext-B8bOKyST.js
  70. 1 1
      static/assets/Toggle-imDq7B6T.js
  71. 0 1
      static/assets/arrow-down--cYuQXi2.js
  72. 1 0
      static/assets/arrow-down-CtKfgWNy.js
  73. 1 0
      static/assets/arrow-left-DAszRgUL.js
  74. 0 1
      static/assets/arrow-left-DMgVJlsY.js
  75. 0 1
      static/assets/arrow-right-BHLBfK4j.js
  76. 1 0
      static/assets/arrow-right-BM58VsWF.js
  77. 0 1
      static/assets/arrow-up-DAnOQUuf.js
  78. 1 0
      static/assets/arrow-up-DOKMVj0r.js
  79. 1 1
      static/assets/arrow-up-down-DeY5owo8.js
  80. 1 1
      static/assets/box-Bk6pltFB.js
  81. 1 1
      static/assets/briefcase-Cr5HjMJR.js
  82. 1 1
      static/assets/calendar-3WeQihFV.js
  83. 1 0
      static/assets/check-DokQZ14y.js
  84. 0 1
      static/assets/check-lecuNcPc.js
  85. 1 1
      static/assets/chevrons-right-BfhMQWqH.js
  86. 0 0
      static/assets/client-D0Q3EIO2.js
  87. 0 0
      static/assets/client-De3QMNnt.js
  88. 1 1
      static/assets/cloud-off-DxX8_NGS.js
  89. 1 1
      static/assets/cog-D0_K-Y1f.js
  90. 1 1
      static/assets/coins-CpLoKEbv.js
  91. 0 1
      static/assets/columns-2-4ppMhNZX.js
  92. 1 0
      static/assets/columns-2-DU_I4QOe.js
  93. 1 1
      static/assets/copy-Czth_pJR.js
  94. 1 1
      static/assets/cpu-DpnBvP4A.js
  95. 1 1
      static/assets/createLucideIcon-BxhAes7f.js
  96. 1 1
      static/assets/dollar-sign-DO-BFrUa.js
  97. 1 1
      static/assets/droplet-DJ0jo-P7.js
  98. 1 1
      static/assets/ellipsis-vertical-qGOu8GMY.js
  99. 1 1
      static/assets/eye-off-Bi3iPp3a.js
  100. 1 1
      static/assets/fan-xXcQPEFP.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
+- **Opening the Queue page could stall Bambuddy for over a minute on a print farm** — The page fetched the whole queue, finished runs included, every five seconds and sorted out the history in the browser. With a few thousand finished runs that was several megabytes per refresh, and building the answer held up every other request: printer cards, the camera, everything, until a proxy in front of Bambuddy gave up with a timeout.
+  - **History is paged on the server:** the History tab loads 50 runs at a time and **Show more** fetches the next 50. Sorting by date, name or printer and the printer, status and location filters are applied by the server. A batch row still counts all of its runs.
+  - **The queue itself** refreshes only pending, printing and skipped jobs every five seconds.
+  - **Clear History is one request** instead of one per finished run, with the same rule as before: a run a batch order still needs is kept as cancelled (#2960). It now clears exactly what the History tab shows, so the location filter counts too, as the confirmation's number already did.
 - **Printer cards never showed the last print, nor the Good / Reject prompt for it** — Each card asked for its printer's archives with the arguments in the wrong order, so it requested none and always got an empty list. The **Last:** line on an idle card and the outcome prompt from #1898 never appeared there.
   - **One request for every card:** the cards now share a single request that returns the archive each printer printed last (a reprint of an older archive counts), instead of one per card. On a farm with 59 printers that was 59 requests on every page load and after every finished print, each scanning the whole archive table for duplicates.
 - **The File Manager and Archives page loaded every thumbnail at once** — With a few hundred files every thumbnail was requested on page load, and the queue of requests slowed down everything else on the server for several seconds. Thumbnails now load as they scroll into view.

+ 291 - 35
backend/app/api/routes/print_queue.py

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

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

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

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

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

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

@@ -443,6 +443,7 @@ export const handlers = [
   // Lists → empty arrays
   http.get('/api/v1/archives/', () => HttpResponse.json([])),
   http.get('/api/v1/archives/last-per-printer', () => HttpResponse.json([])),
+  http.get('/api/v1/queue/history', () => HttpResponse.json({ items: [], total: 0, locations: [] })),
   http.get('/api/v1/auth/oidc/providers', () => HttpResponse.json([])),
   http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([])),
   // OIDC icon proxy (#1333). Default returns a tiny PNG; individual tests

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

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

+ 41 - 0
frontend/src/api/client.ts

@@ -4772,6 +4772,39 @@ export interface AuthStatus {
 }
 
 // API functions
+export interface QueueHistoryFilters {
+  printerId?: number | null;
+  status?: string;
+  location?: string;
+}
+
+export interface QueueHistoryParams extends QueueHistoryFilters {
+  sortBy?: 'date' | 'name' | 'printer';
+  // Flips the default order: newest first by date, A to Z by name or printer.
+  reverse?: boolean;
+  limit?: number;
+  offset?: number;
+}
+
+export interface QueueHistoryPage {
+  // The page in display order, then any other runs of the batches on it.
+  items: PrintQueueItem[];
+  total: number;
+  locations: string[];
+}
+
+function queueHistorySearch(params: QueueHistoryParams): URLSearchParams {
+  const search = new URLSearchParams();
+  if (params.printerId) search.set('printer_id', String(params.printerId));
+  if (params.status) search.set('status', params.status);
+  if (params.location) search.set('location', params.location);
+  if (params.sortBy) search.set('sort_by', params.sortBy);
+  if (params.reverse) search.set('reverse', 'true');
+  if (params.limit !== undefined) search.set('limit', String(params.limit));
+  if (params.offset) search.set('offset', String(params.offset));
+  return search;
+}
+
 export const api = {
   // Overlay branding
   getOverlayLogo: async (token: string | null, signal?: AbortSignal): Promise<Blob | null> => {
@@ -6466,6 +6499,14 @@ export const api = {
     if (targetModel) params.set('target_model', targetModel);
     return request<PrintQueueItem[]>(`/queue/?${params}`);
   },
+  // History (completed / failed / skipped / cancelled), one page at a time.
+  getQueueHistory: (params: QueueHistoryParams) =>
+    request<QueueHistoryPage>(`/queue/history?${queueHistorySearch(params)}`),
+  // Removes the history matching the same filters, server-side.
+  clearQueueHistory: (params: QueueHistoryFilters) =>
+    request<{ cleared: number; kept: number }>(`/queue/history/clear?${queueHistorySearch(params)}`, {
+      method: 'POST',
+    }),
   getQueueItem: (id: number) => request<PrintQueueItem>(`/queue/${id}`),
   addToQueue: (data: PrintQueueItemCreate) =>
     request<PrintQueueItem>('/queue/', {

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

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

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/AdditionalSection-2iJTQSha.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/AmsUnitCard-uz_uOAT6.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ArchivesPage-CqMD3QO7.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/AreaChart-Nx2U3Ac_.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/AssignToAmsModal-XiKBRxai.js


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

@@ -1 +1 @@
-import{C as e}from"./client-D0Q3EIO2.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-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};

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/CamWallPage-BNHUD_N0.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/CameraDiagnoseModal-D5EKxjHy.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/CameraPage-B4aCs6Og.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/CameraWall-DpUYc95q.js


+ 1 - 1
static/assets/Card-Bs7yK0-y.js → static/assets/Card-B_72lsm8.js

@@ -1 +1 @@
-import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n}from"./client-D0Q3EIO2.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-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};

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/CartesianChart-cfP8kvqO.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ColumnConfigModal-RmUrseYv.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ConfigureAmsSlotModal-COaoNS8e.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ConnectAuthorizePage-DUvZFvnL.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ContextMenu-CW2O6tvS.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/CopyButton-CPoSjMIG.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ErrorBarContext-D9KlYt2o.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ExternalLinkPage-CckLLa3j.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 1
static/assets/FileManagerPage-6gPI7pNx.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/FileUploadModal-D1FdeSh4.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/FinancePage-Bj40nSr3.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/GCodeViewerPage-DU-FIu2G.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/GcodeToolpathViewer-C5G3pm5Z.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/GroupEditPage-D0twEjqp.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ImagePreviewModal-CYbgg1Uk.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/InventoryPage-Bl-JqlM1.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/LibraryTrashPage-CWixTgAQ.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/LoginPage-CVj61WhD.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/MaintenancePage-tuvgbi5i.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ModelSourcesPage-zGBPC-gz.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/NotificationsPage-D9PMpI5P.js


+ 1 - 1
static/assets/OverlayFrame-BJ-Bdt_w.js → static/assets/OverlayFrame-BFUz90OE.js

@@ -1 +1 @@
-import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r}from"./client-D0Q3EIO2.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-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};

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 1
static/assets/PdfPreviewModal-C9teSdvB.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/PhotoGalleryModal-Dd4qxPfE.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/PrinterLocationsPage-DwsRt000.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/PrintersPage-ZBn6pjxx.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ProfilesPage-0M2ZM_ix.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ProjectDetailPage-C8DP7INb.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ProjectsPage-DNpCQ6l0.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/QueryClientProvider-CsMjD-7g.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/QueryClientProvider-GUolQzem.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/QueuePage-CTHQ5vQ9.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/QueuePage-Kv8gGfNb.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ReferenceLine-DWoUPJuy.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/RichTextEditor-DGXpd_AB.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SettingsPage-DLstWXjY.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SetupPage-DVPv2pi7.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SliceModal-DIFcnLa8.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SpoolBuddyAmsPage-BhwkPpAv.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-fV4Y4835.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SpoolBuddyDashboard-DBFN6ewV.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-BSfVND4V.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1 - 1
static/assets/SpoolBuddySettingsPage-CF1uzCiN.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-BGl5qJDW.js


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

@@ -1 +1 @@
-import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n}from"./client-D0Q3EIO2.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-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};

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SpreadsheetPreviewModal-Cth51eSm.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/StatsPage-Gq3pWGaf.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/StreamOverlayPage-B8QGRXCM.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SystemInfoPage-CfqUCfNj.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ToastContext-B8bOKyST.js


+ 1 - 1
static/assets/Toggle-I-VDBGwl.js → static/assets/Toggle-imDq7B6T.js

@@ -1 +1 @@
-import{C as e}from"./client-D0Q3EIO2.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-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};

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

@@ -1 +0,0 @@
-import{t as e}from"./createLucideIcon-BTg337Gy.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-CtKfgWNy.js

@@ -0,0 +1 @@
+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-left-DAszRgUL.js

@@ -0,0 +1 @@
+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};

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

@@ -1 +0,0 @@
-import{t as e}from"./createLucideIcon-BTg337Gy.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-BHLBfK4j.js

@@ -1 +0,0 @@
-import{t as e}from"./createLucideIcon-BTg337Gy.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-BM58VsWF.js

@@ -0,0 +1 @@
+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};

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

@@ -1 +0,0 @@
-import{t as e}from"./createLucideIcon-BTg337Gy.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 - 0
static/assets/arrow-up-DOKMVj0r.js

@@ -0,0 +1 @@
+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-MZZbOqYJ.js → static/assets/arrow-up-down-DeY5owo8.js

@@ -1 +1 @@
-import{t as e}from"./createLucideIcon-BTg337Gy.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-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};

+ 1 - 1
static/assets/box-9OC1sZAb.js → static/assets/box-Bk6pltFB.js

@@ -1 +1 @@
-import{t as e}from"./createLucideIcon-BTg337Gy.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-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};

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

@@ -1 +1 @@
-import{t as e}from"./createLucideIcon-BTg337Gy.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-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};

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

@@ -1 +1 @@
-import{t as e}from"./createLucideIcon-BTg337Gy.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-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};

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

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

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

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

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

@@ -1 +1 @@
-import{t as e}from"./createLucideIcon-BTg337Gy.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-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};

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/client-D0Q3EIO2.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/client-De3QMNnt.js


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

@@ -1 +1 @@
-import{t as e}from"./createLucideIcon-BTg337Gy.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-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};

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

@@ -1 +1 @@
-import{t as e}from"./createLucideIcon-BTg337Gy.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-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};

+ 1 - 1
static/assets/coins-dkDEbZk6.js → static/assets/coins-CpLoKEbv.js

@@ -1 +1 @@
-import{t as e}from"./createLucideIcon-BTg337Gy.js";var t=e(`coins`,[[`circle`,{cx:`8`,cy:`8`,r:`6`,key:`3yglwk`}],[`path`,{d:`M18.09 10.37A6 6 0 1 1 10.34 18`,key:`t5s6rm`}],[`path`,{d:`M7 6h1v4`,key:`1obek4`}],[`path`,{d:`m16.71 13.88.7.71-2.82 2.82`,key:`1rbuyh`}]]);export{t};
+import{t as e}from"./createLucideIcon-BxhAes7f.js";var t=e(`coins`,[[`circle`,{cx:`8`,cy:`8`,r:`6`,key:`3yglwk`}],[`path`,{d:`M18.09 10.37A6 6 0 1 1 10.34 18`,key:`t5s6rm`}],[`path`,{d:`M7 6h1v4`,key:`1obek4`}],[`path`,{d:`m16.71 13.88.7.71-2.82 2.82`,key:`1rbuyh`}]]);export{t};

+ 0 - 1
static/assets/columns-2-4ppMhNZX.js

@@ -1 +0,0 @@
-import{t as e}from"./createLucideIcon-BTg337Gy.js";var t=e(`columns-2`,[[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,key:`afitv7`}],[`path`,{d:`M12 3v18`,key:`108xh3`}]]);export{t};

+ 1 - 0
static/assets/columns-2-DU_I4QOe.js

@@ -0,0 +1 @@
+import{t as e}from"./createLucideIcon-BxhAes7f.js";var t=e(`columns-2`,[[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,key:`afitv7`}],[`path`,{d:`M12 3v18`,key:`108xh3`}]]);export{t};

+ 1 - 1
static/assets/copy-BSydjjpn.js → static/assets/copy-Czth_pJR.js

@@ -1 +1 @@
-import{t as e}from"./createLucideIcon-BTg337Gy.js";var t=e(`copy`,[[`rect`,{width:`14`,height:`14`,x:`8`,y:`8`,rx:`2`,ry:`2`,key:`17jyea`}],[`path`,{d:`M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2`,key:`zix9uf`}]]);export{t};
+import{t as e}from"./createLucideIcon-BxhAes7f.js";var t=e(`copy`,[[`rect`,{width:`14`,height:`14`,x:`8`,y:`8`,rx:`2`,ry:`2`,key:`17jyea`}],[`path`,{d:`M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2`,key:`zix9uf`}]]);export{t};

+ 1 - 1
static/assets/cpu-BsH-qpUx.js → static/assets/cpu-DpnBvP4A.js

@@ -1 +1 @@
-import{t as e}from"./createLucideIcon-BTg337Gy.js";var t=e(`cpu`,[[`path`,{d:`M12 20v2`,key:`1lh1kg`}],[`path`,{d:`M12 2v2`,key:`tus03m`}],[`path`,{d:`M17 20v2`,key:`1rnc9c`}],[`path`,{d:`M17 2v2`,key:`11trls`}],[`path`,{d:`M2 12h2`,key:`1t8f8n`}],[`path`,{d:`M2 17h2`,key:`7oei6x`}],[`path`,{d:`M2 7h2`,key:`asdhe0`}],[`path`,{d:`M20 12h2`,key:`1q8mjw`}],[`path`,{d:`M20 17h2`,key:`1fpfkl`}],[`path`,{d:`M20 7h2`,key:`1o8tra`}],[`path`,{d:`M7 20v2`,key:`4gnj0m`}],[`path`,{d:`M7 2v2`,key:`1i4yhu`}],[`rect`,{x:`4`,y:`4`,width:`16`,height:`16`,rx:`2`,key:`1vbyd7`}],[`rect`,{x:`8`,y:`8`,width:`8`,height:`8`,rx:`1`,key:`z9xiuo`}]]);export{t};
+import{t as e}from"./createLucideIcon-BxhAes7f.js";var t=e(`cpu`,[[`path`,{d:`M12 20v2`,key:`1lh1kg`}],[`path`,{d:`M12 2v2`,key:`tus03m`}],[`path`,{d:`M17 20v2`,key:`1rnc9c`}],[`path`,{d:`M17 2v2`,key:`11trls`}],[`path`,{d:`M2 12h2`,key:`1t8f8n`}],[`path`,{d:`M2 17h2`,key:`7oei6x`}],[`path`,{d:`M2 7h2`,key:`asdhe0`}],[`path`,{d:`M20 12h2`,key:`1q8mjw`}],[`path`,{d:`M20 17h2`,key:`1fpfkl`}],[`path`,{d:`M20 7h2`,key:`1o8tra`}],[`path`,{d:`M7 20v2`,key:`4gnj0m`}],[`path`,{d:`M7 2v2`,key:`1i4yhu`}],[`rect`,{x:`4`,y:`4`,width:`16`,height:`16`,rx:`2`,key:`1vbyd7`}],[`rect`,{x:`8`,y:`8`,width:`8`,height:`8`,rx:`1`,key:`z9xiuo`}]]);export{t};

+ 1 - 1
static/assets/createLucideIcon-BTg337Gy.js → static/assets/createLucideIcon-BxhAes7f.js

@@ -1 +1 @@
-import{i as e}from"./chunk-aKtaBQYM.js";import{D as t}from"./client-D0Q3EIO2.js";var n=e(t()),r=e=>e.replace(/([a-z0-9])([A-Z])/g,`$1-$2`).toLowerCase(),i=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(e,t,n)=>n?n.toUpperCase():t.toLowerCase()),a=e=>{let t=i(e);return t.charAt(0).toUpperCase()+t.slice(1)},o=(...e)=>e.filter((e,t,n)=>!!e&&e.trim()!==``&&n.indexOf(e)===t).join(` `).trim(),s=e=>{for(let t in e)if(t.startsWith(`aria-`)||t===`role`||t===`title`)return!0},c={xmlns:`http://www.w3.org/2000/svg`,width:24,height:24,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:2,strokeLinecap:`round`,strokeLinejoin:`round`},l=(0,n.forwardRef)(({color:e=`currentColor`,size:t=24,strokeWidth:r=2,absoluteStrokeWidth:i,className:a=``,children:l,iconNode:u,...d},f)=>(0,n.createElement)(`svg`,{ref:f,...c,width:t,height:t,stroke:e,strokeWidth:i?Number(r)*24/Number(t):r,className:o(`lucide`,a),...!l&&!s(d)&&{"aria-hidden":`true`},...d},[...u.map(([e,t])=>(0,n.createElement)(e,t)),...Array.isArray(l)?l:[l]])),u=(e,t)=>{let i=(0,n.forwardRef)(({className:i,...s},c)=>(0,n.createElement)(l,{ref:c,iconNode:t,className:o(`lucide-${r(a(e))}`,`lucide-${e}`,i),...s}));return i.displayName=a(e),i};export{u as t};
+import{i as e}from"./chunk-aKtaBQYM.js";import{D as t}from"./client-De3QMNnt.js";var n=e(t()),r=e=>e.replace(/([a-z0-9])([A-Z])/g,`$1-$2`).toLowerCase(),i=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(e,t,n)=>n?n.toUpperCase():t.toLowerCase()),a=e=>{let t=i(e);return t.charAt(0).toUpperCase()+t.slice(1)},o=(...e)=>e.filter((e,t,n)=>!!e&&e.trim()!==``&&n.indexOf(e)===t).join(` `).trim(),s=e=>{for(let t in e)if(t.startsWith(`aria-`)||t===`role`||t===`title`)return!0},c={xmlns:`http://www.w3.org/2000/svg`,width:24,height:24,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:2,strokeLinecap:`round`,strokeLinejoin:`round`},l=(0,n.forwardRef)(({color:e=`currentColor`,size:t=24,strokeWidth:r=2,absoluteStrokeWidth:i,className:a=``,children:l,iconNode:u,...d},f)=>(0,n.createElement)(`svg`,{ref:f,...c,width:t,height:t,stroke:e,strokeWidth:i?Number(r)*24/Number(t):r,className:o(`lucide`,a),...!l&&!s(d)&&{"aria-hidden":`true`},...d},[...u.map(([e,t])=>(0,n.createElement)(e,t)),...Array.isArray(l)?l:[l]])),u=(e,t)=>{let i=(0,n.forwardRef)(({className:i,...s},c)=>(0,n.createElement)(l,{ref:c,iconNode:t,className:o(`lucide-${r(a(e))}`,`lucide-${e}`,i),...s}));return i.displayName=a(e),i};export{u as t};

+ 1 - 1
static/assets/dollar-sign-Cn5pcyDA.js → static/assets/dollar-sign-DO-BFrUa.js

@@ -1 +1 @@
-import{t as e}from"./createLucideIcon-BTg337Gy.js";var t=e(`dollar-sign`,[[`line`,{x1:`12`,x2:`12`,y1:`2`,y2:`22`,key:`7eqyqh`}],[`path`,{d:`M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6`,key:`1b0p4s`}]]);export{t};
+import{t as e}from"./createLucideIcon-BxhAes7f.js";var t=e(`dollar-sign`,[[`line`,{x1:`12`,x2:`12`,y1:`2`,y2:`22`,key:`7eqyqh`}],[`path`,{d:`M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6`,key:`1b0p4s`}]]);export{t};

+ 1 - 1
static/assets/droplet-Bpovh5p6.js → static/assets/droplet-DJ0jo-P7.js

@@ -1 +1 @@
-import{t as e}from"./createLucideIcon-BTg337Gy.js";var t=e(`droplet`,[[`path`,{d:`M12 22a7 7 0 0 0 7-7c0-2-1-3.9-3-5.5s-3.5-4-4-6.5c-.5 2.5-2 4.9-4 6.5C6 11.1 5 13 5 15a7 7 0 0 0 7 7z`,key:`c7niix`}]]);export{t};
+import{t as e}from"./createLucideIcon-BxhAes7f.js";var t=e(`droplet`,[[`path`,{d:`M12 22a7 7 0 0 0 7-7c0-2-1-3.9-3-5.5s-3.5-4-4-6.5c-.5 2.5-2 4.9-4 6.5C6 11.1 5 13 5 15a7 7 0 0 0 7 7z`,key:`c7niix`}]]);export{t};

+ 1 - 1
static/assets/ellipsis-vertical-BXVT5eVX.js → static/assets/ellipsis-vertical-qGOu8GMY.js

@@ -1 +1 @@
-import{t as e}from"./createLucideIcon-BTg337Gy.js";var t=e(`ellipsis-vertical`,[[`circle`,{cx:`12`,cy:`12`,r:`1`,key:`41hilf`}],[`circle`,{cx:`12`,cy:`5`,r:`1`,key:`gxeob9`}],[`circle`,{cx:`12`,cy:`19`,r:`1`,key:`lyex9k`}]]);export{t};
+import{t as e}from"./createLucideIcon-BxhAes7f.js";var t=e(`ellipsis-vertical`,[[`circle`,{cx:`12`,cy:`12`,r:`1`,key:`41hilf`}],[`circle`,{cx:`12`,cy:`5`,r:`1`,key:`gxeob9`}],[`circle`,{cx:`12`,cy:`19`,r:`1`,key:`lyex9k`}]]);export{t};

+ 1 - 1
static/assets/eye-off-Bq05MYzN.js → static/assets/eye-off-Bi3iPp3a.js

@@ -1 +1 @@
-import{t as e}from"./createLucideIcon-BTg337Gy.js";var t=e(`eye-off`,[[`path`,{d:`M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49`,key:`ct8e1f`}],[`path`,{d:`M14.084 14.158a3 3 0 0 1-4.242-4.242`,key:`151rxh`}],[`path`,{d:`M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143`,key:`13bj9a`}],[`path`,{d:`m2 2 20 20`,key:`1ooewy`}]]);export{t};
+import{t as e}from"./createLucideIcon-BxhAes7f.js";var t=e(`eye-off`,[[`path`,{d:`M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49`,key:`ct8e1f`}],[`path`,{d:`M14.084 14.158a3 3 0 0 1-4.242-4.242`,key:`151rxh`}],[`path`,{d:`M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143`,key:`13bj9a`}],[`path`,{d:`m2 2 20 20`,key:`1ooewy`}]]);export{t};

+ 1 - 1
static/assets/fan-BZ0HFoG4.js → static/assets/fan-xXcQPEFP.js

@@ -1 +1 @@
-import{t as e}from"./createLucideIcon-BTg337Gy.js";var t=e(`cable`,[[`path`,{d:`M17 19a1 1 0 0 1-1-1v-2a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2a1 1 0 0 1-1 1z`,key:`trhst0`}],[`path`,{d:`M17 21v-2`,key:`ds4u3f`}],[`path`,{d:`M19 14V6.5a1 1 0 0 0-7 0v11a1 1 0 0 1-7 0V10`,key:`1mo9zo`}],[`path`,{d:`M21 21v-2`,key:`eo0ou`}],[`path`,{d:`M3 5V3`,key:`1k5hjh`}],[`path`,{d:`M4 10a2 2 0 0 1-2-2V6a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2a2 2 0 0 1-2 2z`,key:`1dd30t`}],[`path`,{d:`M7 5V3`,key:`1t1388`}]]),n=e(`fan`,[[`path`,{d:`M10.827 16.379a6.082 6.082 0 0 1-8.618-7.002l5.412 1.45a6.082 6.082 0 0 1 7.002-8.618l-1.45 5.412a6.082 6.082 0 0 1 8.618 7.002l-5.412-1.45a6.082 6.082 0 0 1-7.002 8.618l1.45-5.412Z`,key:`484a7f`}],[`path`,{d:`M12 12v.01`,key:`u5ubse`}]]);export{t as n,n as t};
+import{t as e}from"./createLucideIcon-BxhAes7f.js";var t=e(`cable`,[[`path`,{d:`M17 19a1 1 0 0 1-1-1v-2a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2a1 1 0 0 1-1 1z`,key:`trhst0`}],[`path`,{d:`M17 21v-2`,key:`ds4u3f`}],[`path`,{d:`M19 14V6.5a1 1 0 0 0-7 0v11a1 1 0 0 1-7 0V10`,key:`1mo9zo`}],[`path`,{d:`M21 21v-2`,key:`eo0ou`}],[`path`,{d:`M3 5V3`,key:`1k5hjh`}],[`path`,{d:`M4 10a2 2 0 0 1-2-2V6a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2a2 2 0 0 1-2 2z`,key:`1dd30t`}],[`path`,{d:`M7 5V3`,key:`1t1388`}]]),n=e(`fan`,[[`path`,{d:`M10.827 16.379a6.082 6.082 0 0 1-8.618-7.002l5.412 1.45a6.082 6.082 0 0 1 7.002-8.618l-1.45 5.412a6.082 6.082 0 0 1 8.618 7.002l-5.412-1.45a6.082 6.082 0 0 1-7.002 8.618l1.45-5.412Z`,key:`484a7f`}],[`path`,{d:`M12 12v.01`,key:`u5ubse`}]]);export{t as n,n as t};

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio