5 Commity c1ea484a5a ... 29a81520d9

Autor SHA1 Wiadomość Data
  maziggy 29a81520d9 feat(external-links): an app can show a count on its sidebar link (PUT /external-links/badge with "Send notifications"; Bambuddy Orders shows its To do count), the count disappears when not updated for ten minutes 1 dzień temu
  maziggy 16633dd27a fix(printers): Escape closes the Build Plate Check result as soon as it appears (the listener that knows the result is attached in the same render) 1 dzień temu
  maziggy b63189f2be fix(printers): the print slot labels on AMS slots stay on when Bambuddy restarts during a print (the mapping captured from the print command is restored with the print session) 1 dzień temu
  maziggy 9c61a52727 test: close the coroutines that mocked create_task, wait_for, run_coroutine_threadsafe, spawn_background_task and AsyncMock sessions left unawaited (reported as unraisable warnings in unrelated later tests); the Spoolman recovery tests archive under their own tmp_path instead of the shared archive/; the bulk mocks' HA sensor and queue single routes are mocked, so the batched-reads test no longer falls back to per-printer requests, and it waits for the batches to settle before checking 1 dzień temu
  maziggy ee2d6e69aa test: close the coroutines that mocked create_task, wait_for, run_coroutine_threadsafe, spawn_background_task and AsyncMock sessions left unawaited (reported as unraisable warnings in unrelated later tests); the Spoolman recovery tests archive under their own tmp_path instead of the shared archive/ 1 dzień temu
78 zmienionych plików z 579 dodań i 79 usunięć
  1. 3 1
      CHANGELOG.md
  2. 79 4
      backend/app/api/routes/external_links.py
  3. 5 3
      backend/app/core/auth.py
  4. 3 0
      backend/app/core/sqlite_fk.py
  5. 6 0
      backend/app/main.py
  6. 3 2
      backend/app/models/api_key.py
  7. 14 1
      backend/app/models/external_link.py
  8. 1 1
      backend/app/schemas/api_key.py
  9. 16 0
      backend/app/schemas/external_link.py
  10. 8 0
      backend/app/services/usage_tracker.py
  11. 237 0
      backend/tests/integration/test_external_link_badge.py
  12. 5 1
      backend/tests/integration/test_kill_switch_stop_archived_cancelled.py
  13. 12 1
      backend/tests/unit/services/test_printer_manager.py
  14. 8 3
      backend/tests/unit/services/test_smart_plug_manager.py
  15. 10 11
      backend/tests/unit/services/test_virtual_printer.py
  16. 1 0
      backend/tests/unit/test_not_null_update_schemas.py
  17. 1 0
      backend/tests/unit/test_outbound_url_ssrf_guards.py
  18. 9 13
      backend/tests/unit/test_support_helpers.py
  19. 35 0
      backend/tests/unit/test_usage_tracker_restart_recovery.py
  20. 37 0
      frontend/src/__tests__/components/Layout.test.tsx
  21. 4 0
      frontend/src/__tests__/mocks/handlers.ts
  22. 4 0
      frontend/src/__tests__/pages/PrintersPageBatchedReads.test.tsx
  23. 24 0
      frontend/src/__tests__/pages/PrintersPagePlateCheckWiring.test.tsx
  24. 2 0
      frontend/src/api/client.ts
  25. 27 18
      frontend/src/components/Layout.tsx
  26. 1 1
      frontend/src/i18n/locales/de.ts
  27. 1 1
      frontend/src/i18n/locales/en.ts
  28. 1 1
      frontend/src/i18n/locales/es.ts
  29. 1 1
      frontend/src/i18n/locales/fr.ts
  30. 1 1
      frontend/src/i18n/locales/it.ts
  31. 1 1
      frontend/src/i18n/locales/ja.ts
  32. 1 1
      frontend/src/i18n/locales/ko.ts
  33. 1 1
      frontend/src/i18n/locales/nl.ts
  34. 1 1
      frontend/src/i18n/locales/pt-BR.ts
  35. 1 1
      frontend/src/i18n/locales/ru.ts
  36. 1 1
      frontend/src/i18n/locales/sv.ts
  37. 1 1
      frontend/src/i18n/locales/tr.ts
  38. 1 1
      frontend/src/i18n/locales/uk.ts
  39. 1 1
      frontend/src/i18n/locales/zh-CN.ts
  40. 1 1
      frontend/src/i18n/locales/zh-TW.ts
  41. 5 2
      frontend/src/pages/PrintersPage.tsx
  42. 0 0
      static/assets/ArchivesPage-Cijsih6K.js
  43. 0 0
      static/assets/CameraPage-Wnku0dqm.js
  44. 0 0
      static/assets/FileManagerPage-CLYHqJkv.js
  45. 0 0
      static/assets/InventoryPage-BasVhHe0.js
  46. 0 0
      static/assets/Layout-ONJVQ_oD.js
  47. 0 0
      static/assets/ModelSourcesPage-BvLs6J_U.js
  48. 0 0
      static/assets/PhotoGalleryModal-btgMm3DW.js
  49. 0 0
      static/assets/PrintersPage-BsUyKUN8.js
  50. 0 0
      static/assets/SettingsPage-Beo2lGCx.js
  51. 0 0
      static/assets/SliceModal-Cba5GYI7.js
  52. 1 1
      static/assets/SpoolBuddySettingsPage-BeM5JwM3.js
  53. 0 0
      static/assets/de-D39JI_8d.js
  54. 0 0
      static/assets/es-BHL-6pV1.js
  55. 0 0
      static/assets/es-QuPepRKs.js
  56. 0 0
      static/assets/fr-C_28VCx0.js
  57. 0 0
      static/assets/fr-CpEo28WL.js
  58. 0 0
      static/assets/index-BNCUzO2G.js
  59. 0 0
      static/assets/it-CFRvX4yY.js
  60. 0 0
      static/assets/it-Dl6JGckA.js
  61. 0 0
      static/assets/ja-vehaWIqv.js
  62. 0 0
      static/assets/ko-CbTWUFFi.js
  63. 0 0
      static/assets/locationSensorDefaults-BuT1z5wg.js
  64. 0 0
      static/assets/nl-CixN_KdW.js
  65. 0 0
      static/assets/nl-CyemxYPq.js
  66. 0 0
      static/assets/pt-BR-DBfeh188.js
  67. 0 0
      static/assets/pt-BR-sDjXgo3_.js
  68. 0 0
      static/assets/ru-rVUUdG_R.js
  69. 0 0
      static/assets/sv-BbgCZKfM.js
  70. 0 0
      static/assets/sv-Cqpp_hoE.js
  71. 0 0
      static/assets/tr-BXpm1KWk.js
  72. 0 0
      static/assets/tr-Fokmo5P7.js
  73. 0 0
      static/assets/uk-DO3--z4v.js
  74. 0 0
      static/assets/zh-CN-D7IpC6DP.js
  75. 0 0
      static/assets/zh-CN-D8DqooDL.js
  76. 0 0
      static/assets/zh-TW-DARWgtcC.js
  77. 2 2
      static/index.html
  78. 2 0
      test_files_3029_1663994/stale.stl

+ 3 - 1
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.3.0b1] - Unreleased
 
 ### Added
+- **A count on an app's sidebar link** — An app added to the sidebar can show a number on its link, the way the print queue shows how many jobs wait: Bambuddy Orders shows how many orders and top-ups wait in its To do. The app reports the count for its own address with an API key that has **Send notifications** (`PUT /api/v1/external-links/badge`); the link must point at that address. A count not updated for ten minutes disappears, so an app that stopped doesn't leave an old number. Wiki updated.
 - **USB cameras are picked from a list** — With an external camera set to **USB Camera (V4L2)**, Settings > Camera now lists the video devices the Bambuddy server can see, by name and path (`HD Pro Webcam C920 (/dev/video0)`), instead of asking for the device path. A refresh button looks again after a camera is plugged in, and a saved camera that is unplugged stays selected, marked **not found**. When no device is visible, the list says how to pass a camera to the Docker container. **Enter path manually** still takes a typed path. Looking for devices no longer holds up the server while it runs.
 - **The MQTT relay publishes printer offline/online and low filament** — Three new topics for Home Assistant, Node-RED and other subscribers. `printers/{serial}/offline` is sent once a printer has been disconnected for a minute, and `printers/{serial}/online` when it comes back after that, so a short blip sends neither. `printers/{serial}/filament/low` is sent once when the spool in an AMS slot drops below its low-stock threshold, at the same moment as the Low Filament notification, and works even when no notification provider has that event switched on. Wiki updated with the payloads.
 - **Notify! notification provider with iOS Live Activities and Lock Screen widgets (#2919, requested and contributed by @simplytoast1 in #3265)** — Notify! used to need the separate notify-bambuddy companion container. Bambuddy now talks to it directly: add a provider of type Notify! with the device ID and token.
@@ -168,6 +169,7 @@ 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
+- **Escape closes the Build Plate Check result as soon as it appears** — Pressed in the moment the result opened, Escape could be ignored, so the window stayed open and a chamber light the check had turned on stayed on until it was closed another way.
 - **The Printers page sent hundreds of requests on a print farm, and every page asked for each printer's status** — Each printer card fetched its own status, slot presets, AMS labels, power plug, action buttons, sensor readings, firmware check and queue, one request per printer. With 59 printers that was about 550 requests when the page opened, which the server worked through one behind the other, so cards took 5 to 20 seconds to fill. The sidebar, the camera wall and other pages also fetched every printer's status one by one.
   - **Now batched:** requests for the same kind of data that arrive within a few milliseconds go out as one request, so the Printers page sends about one request per kind of data however many printers there are. The cards' own refresh timers are unchanged, including the 30-second status poll, and they too are combined into one request each round.
   - **Same answers:** each combined request returns exactly what the per-printer one did, with the same permissions and printer access. Refreshing a single printer after an action, or a page that shows one printer, still sends the per-printer request.
@@ -317,7 +319,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Stopping drying says the command was sent** — The toast said "Drying stopped" as soon as the command went out; it now says "Stop command sent".
 - **A skip the printer refuses or never applies is logged** — Skipped objects were marked skipped the moment the command was sent. A refusal from the printer now takes them back off the skipped list and is logged with the printer's reason, and a skip the printer has not listed after a minute is logged as unconfirmed.
 - **The printer card shows a nozzle change, firmware update or developer-mode switch at once** — These were left out of the live updates and only reached the card with the 30-second refresh or a page reload.
-- **The print slot labels on AMS slots show up** — While a print runs, each AMS slot the print uses is meant to carry a small "P1 / P2" label, so a job sent to a printer with the wrong filament stands out. The labels read the print's mapping from the printer's own status, which almost never carries it, so they rarely appeared. Bambuddy already records the mapping from the print command, and the labels now use it. A print started from the printer's screen after a job that never started (refused, or cancelled before printing) no longer inherits that job's mapping, which usage tracking read as well.
+- **The print slot labels on AMS slots show up** — While a print runs, each AMS slot the print uses is meant to carry a small "P1 / P2" label, so a job sent to a printer with the wrong filament stands out. The labels read the print's mapping from the printer's own status, which almost never carries it, so they rarely appeared. Bambuddy already records the mapping from the print command, and the labels now use it. They stay on when Bambuddy is restarted while the print runs. A print started from the printer's screen after a job that never started (refused, or cancelled before printing) no longer inherits that job's mapping, which usage tracking read as well.
 - **Drying no longer offers spool rotation on an AMS with filament in the feed tube** — **Rotate spool during drying** was disabled only for the tray state that means "loaded" on some firmware, so on others it stayed available and the printer refused the drying start. The slot the printer reports as loaded, and on two-nozzle printers the slot each nozzle is fed from, now count too.
 - **The run-out hint names the slot on two-nozzle printers with several AMS** — When a print paused for filament, the hint often said to check the printer screen, because the slot number alone does not say which AMS it is on. The AMS wired to the active nozzle now settles it; with the Filament Track Switch, where an AMS can feed either nozzle, the hint still asks.
 - **A regular AMS is never shown as an AMS-HT** — An AMS was taken for an AMS-HT when it reported a single tray. The unit number (128-135 for AMS-HT) now decides, on the printer card and its slot labels ("HT-B" became "AMS-B"), in the AMS Filament Backup view, print mapping, the slicer's loaded-spool list and the queue. A regular AMS that reports only some of its trays shows each one in its own slot rather than in list order.

+ 79 - 4
backend/app/api/routes/external_links.py

@@ -2,25 +2,37 @@
 
 import logging
 import uuid
+from datetime import timedelta
 from pathlib import Path
+from typing import cast
+from urllib.parse import urlsplit
 
 from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
 from fastapi.responses import FileResponse
-from sqlalchemy import select
+from sqlalchemy import select, update
+from sqlalchemy.engine import CursorResult
+from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_media_token_permission
+from backend.app.core.auth import (
+    RequirePermissionIfAuthEnabled,
+    ScopedCaller,
+    require_media_token_permission,
+    require_notification_send,
+)
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
-from backend.app.models.external_link import ExternalLink
+from backend.app.models.external_link import ExternalLink, ExternalLinkBadge
 from backend.app.models.user import User
 from backend.app.schemas.external_link import (
+    ExternalLinkBadgeSet,
     ExternalLinkCreate,
     ExternalLinkReorder,
     ExternalLinkResponse,
     ExternalLinkUpdate,
 )
+from backend.app.utils.local_time import utcnow_naive
 
 # Directory for storing custom icons
 ICONS_DIR = app_settings.base_dir / "icons"
@@ -30,6 +42,24 @@ logger = logging.getLogger(__name__)
 
 router = APIRouter(prefix="/external-links", tags=["external-links"])
 
+# An app that stopped reporting (stopped, crashed) leaves no old number behind.
+BADGE_FRESH_FOR = timedelta(minutes=10)
+
+
+_DEFAULT_PORTS = {"http": 80, "https": 443}
+
+
+def _same_place(url: str) -> tuple[str, str, int | None, str]:
+    """Scheme, host, port and path of an address. A login in it, the default
+    port written out and a trailing slash don't count."""
+    parts = urlsplit(url.strip())
+    scheme = parts.scheme.lower()
+    try:
+        port = parts.port or _DEFAULT_PORTS.get(scheme)
+    except ValueError:
+        port = None
+    return scheme, (parts.hostname or "").lower(), port, parts.path.rstrip("/")
+
 
 @router.get("/", response_model=list[ExternalLinkResponse])
 async def list_external_links(
@@ -39,7 +69,52 @@ async def list_external_links(
     """List all external links ordered by sort_order."""
     result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id))
     links = result.scalars().all()
-    return links
+    fresh_since = utcnow_naive() - BADGE_FRESH_FOR
+    badges = {
+        b.link_id: b.count
+        for b in (await db.execute(select(ExternalLinkBadge).where(ExternalLinkBadge.updated_at >= fresh_since)))
+        .scalars()
+        .all()
+    }
+    return [
+        ExternalLinkResponse.model_validate(link).model_copy(update={"badge_count": badges.get(link.id)})
+        for link in links
+    ]
+
+
+@router.put("/badge")
+async def set_badge(
+    data: ExternalLinkBadgeSet,
+    db: AsyncSession = Depends(get_db),
+    _: ScopedCaller = Depends(require_notification_send()),
+):
+    """Set the count on the sidebar links pointing at an app, e.g. Bambuddy
+    Orders' To do. For the app itself: an API key needs the "Send
+    notifications" permission, like for its messages. The count shows while
+    the app keeps reporting it."""
+    wanted = _same_place(data.url)
+    links = [
+        link for link in (await db.execute(select(ExternalLink))).scalars().all() if _same_place(link.url) == wanted
+    ]
+    if not links:
+        raise HTTPException(status_code=404, detail="No sidebar link points at this address")
+    now = utcnow_naive()
+    for link in links:
+        values = {"count": data.count, "updated_at": now}
+        changed = cast(
+            CursorResult,
+            await db.execute(update(ExternalLinkBadge).where(ExternalLinkBadge.link_id == link.id).values(**values)),
+        )
+        if changed.rowcount:
+            continue
+        try:
+            async with db.begin_nested():
+                db.add(ExternalLinkBadge(link_id=link.id, **values))
+        except IntegrityError:
+            # Another report made the row in the meantime: update that one.
+            await db.execute(update(ExternalLinkBadge).where(ExternalLinkBadge.link_id == link.id).values(**values))
+    await db.commit()
+    return {"links": len(links)}
 
 
 @router.post("/", response_model=ExternalLinkResponse)

+ 5 - 3
backend/app/core/auth.py

@@ -642,9 +642,11 @@ def require_energy_cost_update():
 
 
 def require_notification_send():
-    """Dependency for ``POST /notifications/app-message``: another application
-    sending a message through the channels that accept app messages. API keys
-    need ``can_send_notifications``; users need ``NOTIFICATIONS_UPDATE``."""
+    """Dependency for ``POST /notifications/app-message`` and ``PUT
+    /external-links/badge``: another application sending a message through the
+    channels that accept app messages, or setting the count on its sidebar
+    link. API keys need ``can_send_notifications``; users need
+    ``NOTIFICATIONS_UPDATE``."""
     return require_api_key_scope(
         "can_send_notifications", "send_notifications", Permission.NOTIFICATIONS_UPDATE, owner_needs_permission=True
     )

+ 3 - 0
backend/app/core/sqlite_fk.py

@@ -92,6 +92,9 @@ APPLIED: frozenset[tuple[str, str]] = frozenset(
         ("slicer_pipelines", "target_printer_id"),
         # A deleted printer's spools would stay "assigned" to it (SA-1).
         ("spool_assignment", "printer_id"),
+        # external_links: the next link given the id would show the count an
+        # app reported for the deleted one.
+        ("external_link_badges", "link_id"),
         # smart_plugs: a printer delete now deletes its plugs; their energy
         # history would pass to the next plug given the id.
         ("smart_plug_energy_snapshots", "plug_id"),

+ 6 - 0
backend/app/main.py

@@ -6797,6 +6797,7 @@ async def _restore_usage_tracking_session(printer_id: int, state, db, logger) ->
         from backend.app.api.routes.settings import get_setting
         from backend.app.services.usage_tracker import (
             clear_persisted_session,
+            get_persisted_ams_mapping,
             get_persisted_print_name,
             restore_session,
         )
@@ -6824,6 +6825,11 @@ async def _restore_usage_tracking_session(printer_id: int, state, db, logger) ->
                 printer_id,
                 register_active=not (bool(_spoolman_on) and _spoolman_on.lower() == "true"),
             )
+            # The slot pills (#1762) read the mapping captured from the print
+            # command, which dies with the process as well. One this process
+            # has captured since is newer.
+            if state.print_ams_mapping is None:
+                state.print_ams_mapping = await get_persisted_ams_mapping(db, printer_id)
         if persisted_log:
             restored = [tuple(entry) for entry in persisted_log if isinstance(entry, (list, tuple)) and len(entry) == 2]
             # Anything this process already observed goes after the persisted

+ 3 - 2
backend/app/models/api_key.py

@@ -56,8 +56,9 @@ class APIKey(Base):
     # could rewrite SMTP/LDAP/MQTT credentials).
     can_update_energy_cost: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
     # Send a message through the notification channels that accept app
-    # messages (POST /notifications/app-message). Nothing else: no reading or
-    # changing the channels themselves.
+    # messages (POST /notifications/app-message), and set the count on the
+    # sidebar link pointing at the app (PUT /external-links/badge). Nothing
+    # else: no reading or changing the channels or the links themselves.
     can_send_notifications: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
 
     # Optional scope limits

+ 14 - 1
backend/app/models/external_link.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 
-from sqlalchemy import Boolean, DateTime, Integer, String, func
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
 from sqlalchemy.orm import Mapped, mapped_column
 
 from backend.app.core.database import Base
@@ -20,3 +20,16 @@ class ExternalLink(Base):
     sort_order: Mapped[int] = mapped_column(Integer, default=0)
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+
+
+class ExternalLinkBadge(Base):
+    """A count on an external sidebar link, set by the app the link points to
+    (Bambuddy Orders' To do, say). Kept apart from the link so that setting it
+    every minute leaves the link's own ``updated_at`` alone."""
+
+    __tablename__ = "external_link_badges"
+
+    link_id: Mapped[int] = mapped_column(ForeignKey("external_links.id", ondelete="CASCADE"), primary_key=True)
+    count: Mapped[int] = mapped_column(Integer, default=0)
+    # When the app last reported; an old count isn't shown.
+    updated_at: Mapped[datetime] = mapped_column(DateTime)

+ 1 - 1
backend/app/schemas/api_key.py

@@ -21,7 +21,7 @@ class APIKeyCreate(BaseModel):
     can_manage_projects: bool = True  # Create/update/delete projects + membership (add archives) (#1893)
     can_access_cloud: bool = False  # Read /cloud/* on the creator's behalf — default off (#1182)
     can_update_energy_cost: bool = False  # POST /settings/electricity-price only (#1356)
-    can_send_notifications: bool = False  # POST /notifications/app-message only
+    can_send_notifications: bool = False  # POST /notifications/app-message, PUT /external-links/badge only
     printer_ids: list[int] | None = None  # null = all printers
     expires_at: datetime | None = None
 

+ 16 - 0
backend/app/schemas/external_link.py

@@ -56,10 +56,26 @@ class ExternalLinkResponse(ExternalLinkBase):
     sort_order: int
     created_at: datetime
     updated_at: datetime
+    # Set by the app the link points to, while it keeps reporting; None otherwise.
+    badge_count: int | None = None
 
     model_config = {"from_attributes": True}
 
 
+class ExternalLinkBadgeSet(BaseModel):
+    """An app's count for the sidebar link pointing at it."""
+
+    url: str = Field(..., min_length=1, max_length=500, description="The app's address, as the link has it")
+    count: int = Field(..., ge=0, le=1_000_000)
+
+    @field_validator("url")
+    @classmethod
+    def validate_url(cls, v: str) -> str:
+        if not v.startswith(("http://", "https://")):
+            raise ValueError("URL must start with http:// or https://")
+        return v
+
+
 class ExternalLinkReorder(BaseModel):
     """Schema for reordering external links."""
 

+ 8 - 0
backend/app/services/usage_tracker.py

@@ -419,6 +419,14 @@ async def get_persisted_print_name(db: AsyncSession, printer_id: int) -> str | N
     return row.print_name if row is not None else None
 
 
+async def get_persisted_ams_mapping(db: AsyncSession, printer_id: int) -> list[int] | None:
+    """AMS mapping on the persisted row: the one captured from the print command."""
+    from backend.app.models.active_print_session import ActivePrintSession
+
+    row = await db.get(ActivePrintSession, printer_id)
+    return list(row.ams_mapping) if row is not None and row.ams_mapping else None
+
+
 async def restore_session(db: AsyncSession, printer_id: int, register_active: bool = True) -> list[list[int]] | None:
     """Rebuild the in-memory session for ``printer_id`` from the persisted row.
 

+ 237 - 0
backend/tests/integration/test_external_link_badge.py

@@ -0,0 +1,237 @@
+"""A count on an external sidebar link, set by the app it points to.
+
+The contract these tests pin:
+
+  ``PUT /external-links/badge`` with ``{url, count}`` sets the count on every
+  sidebar link pointing at that address (same origin and path, trailing slash
+  ignored), e.g. Bambuddy Orders with its To do count. Callers need what
+  ``POST /notifications/app-message`` needs: an API key with the "Send
+  notifications" permission, or a user with ``notifications:update``. The
+  list of links carries the count while it is fresh (set within the last ten
+  minutes), so an app that stopped reporting doesn't leave an old number.
+  Setting it doesn't touch the link's own ``updated_at``.
+"""
+
+from datetime import timedelta
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import generate_api_key
+from backend.app.models.api_key import APIKey
+from backend.app.models.external_link import ExternalLink, ExternalLinkBadge
+from backend.app.models.user import User
+from backend.app.utils.local_time import utcnow_naive
+
+URL = "/api/v1/external-links/badge"
+
+
+async def _admin_token(client: AsyncClient) -> str:
+    await client.post(
+        "/api/v1/auth/setup",
+        json={
+            "auth_enabled": True,
+            "admin_username": "badgeadmin",
+            "admin_password": "AdminPass1!",  # pragma: allowlist secret
+        },
+    )
+    login = await client.post(
+        "/api/v1/auth/login",
+        json={"username": "badgeadmin", "password": "AdminPass1!"},  # pragma: allowlist secret
+    )
+    return login.json()["access_token"]
+
+
+async def _key(db: AsyncSession, *, allowed: bool) -> str:
+    owner = (await db.execute(select(User).where(User.username == "badgeadmin"))).scalar_one()
+    full_key, key_hash, key_prefix = generate_api_key()
+    db.add(
+        APIKey(
+            name="Bambuddy Orders",
+            key_hash=key_hash,
+            key_prefix=key_prefix,
+            user_id=owner.id,
+            can_send_notifications=allowed,
+        )
+    )
+    await db.commit()
+    return full_key
+
+
+async def _link(db: AsyncSession, url: str, name: str = "Orders") -> ExternalLink:
+    link = ExternalLink(name=name, url=url, icon="Package", sort_order=0)
+    db.add(link)
+    await db.commit()
+    await db.refresh(link)
+    return link
+
+
+async def _listed(client: AsyncClient, token: str) -> dict[str, int | None]:
+    response = await client.get("/api/v1/external-links/", headers={"Authorization": f"Bearer {token}"})
+    assert response.status_code == 200, response.text
+    return {link["name"]: link["badge_count"] for link in response.json()}
+
+
+class TestBadge:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_app_sets_the_count_on_the_link_pointing_at_it(self, async_client: AsyncClient, db_session):
+        token = await _admin_token(async_client)
+        key = await _key(db_session, allowed=True)
+        await _link(db_session, "http://orders.local:8090/")
+        await _link(db_session, "http://other.local:8090/", name="Other")
+
+        response = await async_client.put(
+            URL, json={"url": "http://orders.local:8090", "count": 7}, headers={"X-API-Key": key}
+        )
+        assert response.status_code == 200, response.text
+        assert response.json() == {"links": 1}
+        assert await _listed(async_client, token) == {"Orders": 7, "Other": None}
+
+        await async_client.put(URL, json={"url": "http://orders.local:8090/", "count": 0}, headers={"X-API-Key": key})
+        assert (await _listed(async_client, token))["Orders"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_path_counts_too(self, async_client: AsyncClient, db_session):
+        token = await _admin_token(async_client)
+        key = await _key(db_session, allowed=True)
+        await _link(db_session, "https://shop.example/orders/")
+        await _link(db_session, "https://shop.example/print", name="Print")
+
+        await async_client.put(URL, json={"url": "https://shop.example/orders", "count": 2}, headers={"X-API-Key": key})
+        assert await _listed(async_client, token) == {"Orders": 2, "Print": None}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_no_link_pointing_there_is_a_404(self, async_client: AsyncClient, db_session):
+        await _admin_token(async_client)
+        key = await _key(db_session, allowed=True)
+        await _link(db_session, "http://orders.local:8090/")
+        response = await async_client.put(
+            URL, json={"url": "http://orders.local:9999", "count": 1}, headers={"X-API-Key": key}
+        )
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_old_count_is_not_shown(self, async_client: AsyncClient, db_session):
+        token = await _admin_token(async_client)
+        key = await _key(db_session, allowed=True)
+        await _link(db_session, "http://orders.local:8090")
+        await async_client.put(URL, json={"url": "http://orders.local:8090", "count": 3}, headers={"X-API-Key": key})
+        await db_session.execute(update(ExternalLinkBadge).values(updated_at=utcnow_naive() - timedelta(minutes=11)))
+        await db_session.commit()
+        assert (await _listed(async_client, token))["Orders"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_link_itself_is_not_changed(self, async_client: AsyncClient, db_session):
+        await _admin_token(async_client)
+        key = await _key(db_session, allowed=True)
+        link = await _link(db_session, "http://orders.local:8090")
+        before = link.updated_at
+        await async_client.put(URL, json={"url": "http://orders.local:8090", "count": 3}, headers={"X-API-Key": key})
+        stored = await db_session.execute(select(ExternalLink.updated_at).where(ExternalLink.id == link.id))
+        assert stored.scalar_one() == before
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deleting_the_link_drops_its_count(self, async_client: AsyncClient, db_session):
+        token = await _admin_token(async_client)
+        key = await _key(db_session, allowed=True)
+        link = await _link(db_session, "http://orders.local:8090")
+        await async_client.put(URL, json={"url": "http://orders.local:8090", "count": 3}, headers={"X-API-Key": key})
+        response = await async_client.delete(
+            f"/api/v1/external-links/{link.id}", headers={"Authorization": f"Bearer {token}"}
+        )
+        assert response.status_code == 200
+        assert (await db_session.execute(select(ExternalLinkBadge))).scalars().all() == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_needs_the_send_notifications_permission(self, async_client: AsyncClient, db_session):
+        await _admin_token(async_client)
+        key = await _key(db_session, allowed=False)
+        await _link(db_session, "http://orders.local:8090")
+        body = {"url": "http://orders.local:8090", "count": 1}
+        assert (await async_client.put(URL, json=body, headers={"X-API-Key": key})).status_code == 403
+        assert (await async_client.put(URL, json=body)).status_code == 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_refuses_nonsense(self, async_client: AsyncClient, db_session):
+        await _admin_token(async_client)
+        key = await _key(db_session, allowed=True)
+        for body in ({"url": "ftp://orders.local", "count": 1}, {"url": "http://orders.local", "count": -1}):
+            assert (await async_client.put(URL, json=body, headers={"X-API-Key": key})).status_code == 422
+
+
+class TestMatching:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_same_address_written_differently_still_matches(self, async_client: AsyncClient, db_session):
+        token = await _admin_token(async_client)
+        key = await _key(db_session, allowed=True)
+        await _link(db_session, "http://orders.local", name="Default port")
+        await _link(db_session, "https://user:pass@shop.example/", name="With login")  # pragma: allowlist secret
+        await _link(db_session, "http://ORDERS.local:80/", name="Spelled out")
+
+        response = await async_client.put(
+            URL, json={"url": "http://orders.local:80", "count": 4}, headers={"X-API-Key": key}
+        )
+        assert response.json() == {"links": 2}
+        await async_client.put(URL, json={"url": "https://shop.example:443", "count": 5}, headers={"X-API-Key": key})
+        assert await _listed(async_client, token) == {"Default port": 4, "With login": 5, "Spelled out": 4}
+
+
+class TestWhoMaySet:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_signed_in_user_needs_notifications_update(self, async_client: AsyncClient, db_session):
+        token = await _admin_token(async_client)
+        await _link(db_session, "http://orders.local:8090")
+        response = await async_client.put(
+            URL, json={"url": "http://orders.local:8090", "count": 2}, headers={"Authorization": f"Bearer {token}"}
+        )
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_key_can_do_no_more_than_its_owner(self, async_client: AsyncClient, db_session):
+        from backend.app.models.group import Group
+
+        await _admin_token(async_client)
+        viewers = (await db_session.execute(select(Group).where(Group.name == "Viewers"))).scalar_one()
+        owner = User(username="viewer", password_hash="x", role="user", is_active=True)
+        owner.groups = [viewers]
+        db_session.add(owner)
+        await db_session.commit()
+        full_key, key_hash, key_prefix = generate_api_key()
+        db_session.add(
+            APIKey(name="k", key_hash=key_hash, key_prefix=key_prefix, user_id=owner.id, can_send_notifications=True)
+        )
+        await db_session.commit()
+        await _link(db_session, "http://orders.local:8090")
+        response = await async_client.put(
+            URL, json={"url": "http://orders.local:8090", "count": 2}, headers={"X-API-Key": full_key}
+        )
+        assert response.status_code == 403
+
+
+class TestLinkGone:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_link_removed_any_way_takes_its_count_along(self, async_client: AsyncClient, db_session):
+        """Not only through the delete route: SQLite applies no ON DELETE by itself."""
+        token = await _admin_token(async_client)
+        key = await _key(db_session, allowed=True)
+        link = await _link(db_session, "http://orders.local:8090")
+        await async_client.put(URL, json={"url": "http://orders.local:8090", "count": 3}, headers={"X-API-Key": key})
+        await db_session.delete(await db_session.get(ExternalLink, link.id))
+        await db_session.commit()
+        assert (await db_session.execute(select(ExternalLinkBadge))).scalars().all() == []
+        await _link(db_session, "http://other.local:8090", name="Next")
+        assert await _listed(async_client, token) == {"Next": None}

+ 5 - 1
backend/tests/integration/test_kill_switch_stop_archived_cancelled.py

@@ -35,7 +35,11 @@ async def _complete(printer_id, status, update):
     session = AsyncMock()
     session.__aenter__ = AsyncMock(return_value=session)
     session.__aexit__ = AsyncMock(return_value=False)
-    session.execute = AsyncMock(return_value=MagicMock())
+    # Plain mocks for what the reads return: an AsyncMock's results are
+    # AsyncMocks too, so a plain call on one (completed_at.astimezone(),
+    # .first()) leaves an unawaited coroutine, reported in a later test.
+    for read in ("execute", "get", "scalar", "scalars"):
+        setattr(session, read, AsyncMock(return_value=MagicMock()))
     tasks_before = set(asyncio.all_tasks())
     with (
         patch("backend.app.main.async_session", return_value=session),

+ 12 - 1
backend/tests/unit/services/test_printer_manager.py

@@ -422,10 +422,21 @@ class TestPrinterManager:
         mock_loop.is_running.return_value = True
         manager._loop = mock_loop
 
-        manager.mark_printer_offline(1)
+        # The stand-in loop never runs the callback, so the scheduled
+        # coroutine is closed here instead of being reported in a later test.
+        def _close_and_stand_in(coro, loop):
+            coro.close()
+            return MagicMock()
+
+        with patch(
+            "backend.app.services.printer_manager.asyncio.run_coroutine_threadsafe",
+            side_effect=_close_and_stand_in,
+        ) as scheduled:
+            manager.mark_printer_offline(1)
 
         # Callback should be scheduled via run_coroutine_threadsafe
         mock_loop.is_running.assert_called()
+        scheduled.assert_called_once()
         # State should be updated
         assert mock_client.state.connected is False
 

+ 8 - 3
backend/tests/unit/services/test_smart_plug_manager.py

@@ -375,15 +375,20 @@ class TestSmartPlugManager:
         """Verify scheduler can be started."""
         assert manager._scheduler_task is None
 
-        # Mock _schedule_loop to return a mock coroutine to avoid unawaited coroutine warning
+        # The patched loops are AsyncMocks, so each call still builds a
+        # coroutine: the stand-in create_task closes it, or it is reported
+        # unawaited in a later test.
+        def _close_and_stand_in(coro, **_):
+            coro.close()
+            return MagicMock()
+
         with (
             patch.object(manager, "_schedule_loop") as mock_loop,
             patch.object(manager, "_snapshot_loop"),
             patch.object(manager, "_power_alert_loop") as mock_power_alert,
             patch.object(manager, "_relay_energy_loop") as mock_relay_energy,
-            patch("asyncio.create_task") as mock_create,
+            patch("asyncio.create_task", side_effect=_close_and_stand_in),
         ):
-            mock_create.return_value = MagicMock()
             manager.start_scheduler()
 
             assert manager._scheduler_task is not None

+ 10 - 11
backend/tests/unit/services/test_virtual_printer.py

@@ -4213,21 +4213,20 @@ class TestSlicerProxyManager:
         )
 
         # Mock asyncio.create_task and asyncio.gather to prevent actual
-        # server start. Close every coroutine handed to gather — otherwise
-        # the ~110 run_with_logging() coros built inside start() are
-        # garbage-collected unfinalized and surface later as
-        # PytestUnraisableExceptionWarning at random in other tests.
-        async def _close_pending(*coros, **_):
-            for c in coros:
-                if asyncio.iscoroutine(c):
-                    c.close()
+        # server start. Close every coroutine handed to create_task (gather
+        # only sees the stand-in tasks) — otherwise the ~110
+        # run_with_logging() coros built inside start() are garbage-collected
+        # unfinalized and surface later as PytestUnraisableExceptionWarning at
+        # random in other tests.
+        def _close_and_stand_in(coro, **_):
+            coro.close()
+            return MagicMock()
 
         with (
-            patch("asyncio.create_task") as mock_create_task,
-            patch("asyncio.gather", side_effect=_close_pending),
+            patch("asyncio.create_task", side_effect=_close_and_stand_in),
+            patch("asyncio.gather", new=AsyncMock()),
             patch.object(SlicerProxyManager, "_log_activity"),
         ):
-            mock_create_task.return_value = MagicMock()
             await mgr.start()
 
         # FTP, FileTransfer, RTSP should be TCPProxy (transparent)

+ 1 - 0
backend/tests/unit/test_not_null_update_schemas.py

@@ -23,6 +23,7 @@ NOT_A_ROW_UPDATE = {
     "LinkTagRequest": "links a tag through its own rules",
     "ShoppingListItemStatusUpdate": "one required field",
     "ExternalLinkReorder": "a list of ids",
+    "ExternalLinkBadgeSet": "an app's count for its links, every field required",
     "SlicerSettingUpdate": "a Bambu Cloud setting, not a database row",
     "SpoolmanInventoryUpdate": "a Spoolman spool, not a database row",
     "SpoolWeightUpdate": "a Spoolman spool, not a database row",

+ 1 - 0
backend/tests/unit/test_outbound_url_ssrf_guards.py

@@ -675,6 +675,7 @@ NOT_A_FETCH_TARGET = {
     ("SystemConfigRequest", "backend_url"),
     ("ExternalLinkCreate", "url"),  # sidebar link, rendered in the UI, never requested
     ("ExternalLinkUpdate", "url"),
+    ("ExternalLinkBadgeSet", "url"),  # compared with the sidebar links' addresses, never requested
     ("AppMessage", "url"),  # an app's link, appended to the notification text; never requested
     ("FileUpdate", "external_url"),  # library file link (#3077), rendered in the UI, never fetched
     ("MaintenanceTypeCreate", "wiki_url"),  # documentation link surfaced in the UI/notifications

+ 9 - 13
backend/tests/unit/test_support_helpers.py

@@ -118,15 +118,9 @@ class TestCheckPort:
     async def test_unreachable_port(self):
         from backend.app.api.routes.support import _check_port
 
-        with (
-            patch(
-                "backend.app.api.routes.support.asyncio.open_connection",
-                side_effect=ConnectionRefusedError,
-            ),
-            patch(
-                "backend.app.api.routes.support.asyncio.wait_for",
-                side_effect=ConnectionRefusedError,
-            ),
+        with patch(
+            "backend.app.api.routes.support.asyncio.open_connection",
+            side_effect=ConnectionRefusedError,
         ):
             result = await _check_port("192.168.1.1", 8883, timeout=1.0)
 
@@ -137,10 +131,12 @@ class TestCheckPort:
     async def test_timeout(self):
         from backend.app.api.routes.support import _check_port
 
-        with patch(
-            "backend.app.api.routes.support.asyncio.wait_for",
-            side_effect=asyncio.TimeoutError,
-        ):
+        # Closes the connect it was handed: left unawaited, it is reported in a later test.
+        async def _times_out(coro, timeout):
+            coro.close()
+            raise TimeoutError
+
+        with patch("backend.app.api.routes.support.asyncio.wait_for", side_effect=_times_out):
             result = await _check_port("192.168.1.1", 8883, timeout=0.1)
 
         assert result is False

+ 35 - 0
backend/tests/unit/test_usage_tracker_restart_recovery.py

@@ -624,6 +624,7 @@ class TestRestoreOnRestartRecovery:
             tray_now=3,
             layer_num=700,
             last_loaded_tray=-1,
+            print_ams_mapping=None,
         )
         for key, value in overrides.items():
             setattr(state, key, value)
@@ -653,6 +654,36 @@ class TestRestoreOnRestartRecovery:
         assert state.tray_change_log == [(2, 0)]
         assert _active_sessions[printer.id].plate_id == 1
 
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("spoolman", [False, True])
+    async def test_the_print_s_slot_mapping_comes_back_onto_the_printer_state(self, db_session, printer, spoolman):
+        """The slot pills (#1762) read the mapping captured from the print
+        command, which dies with the process; with Spoolman too, which gets no
+        in-memory session."""
+        from backend.app.main import _restore_usage_tracking_session
+        from backend.app.models.settings import Settings
+
+        if spoolman:
+            db_session.add(Settings(key="spoolman_enabled", value="true"))
+            await db_session.commit()
+        await persist_session(db_session, self._session(printer.id), [(2, 0)])
+        state = self._state()
+
+        await _restore_usage_tracking_session(printer.id, state, db_session, MagicMock())
+
+        assert state.print_ams_mapping == [2]
+
+    @pytest.mark.asyncio
+    async def test_a_mapping_this_process_captured_is_kept(self, db_session, printer):
+        from backend.app.main import _restore_usage_tracking_session
+
+        await persist_session(db_session, self._session(printer.id), [(2, 0)])
+        state = self._state(print_ams_mapping=[5, 10])
+
+        await _restore_usage_tracking_session(printer.id, state, db_session, MagicMock())
+
+        assert state.print_ams_mapping == [5, 10]
+
     @pytest.mark.asyncio
     async def test_entries_seen_by_this_process_are_kept_after_the_persisted_ones(self, db_session, printer):
         from backend.app.main import _restore_usage_tracking_session
@@ -676,6 +707,8 @@ class TestRestoreOnRestartRecovery:
 
         assert state.tray_change_log == [(3, 700)]
         assert state.last_loaded_tray == 3
+        # And no slot pills: nothing says how the print's filaments were mapped.
+        assert state.print_ams_mapping is None
 
     @pytest.mark.asyncio
     async def test_unloaded_tray_seeds_nothing(self, db_session, printer):
@@ -703,6 +736,8 @@ class TestRestoreOnRestartRecovery:
         assert await restore_session(db_session, printer.id) is None
         # Still seeded, so the rest of the running print stays attributable.
         assert state.tray_change_log == [(3, 700)]
+        # Nor do its slot pills show on the running print.
+        assert state.print_ams_mapping is None
 
     @pytest.mark.asyncio
     async def test_an_unloaded_tray_does_not_clobber_last_loaded_tray(self, db_session, printer):

+ 37 - 0
frontend/src/__tests__/components/Layout.test.tsx

@@ -99,6 +99,43 @@ describe('Layout', () => {
     });
   });
 
+  describe('external link count', () => {
+    // A connected app (Bambuddy Orders) reports its To do count for the link
+    // pointing at it; the sidebar shows it like the queue's badge.
+    const link = (id: number, name: string, badge_count: number | null, open_in_new_tab = false) => ({
+      id,
+      name,
+      url: `http://app${id}.local:8090`,
+      icon: 'Package',
+      open_in_new_tab,
+      custom_icon: null,
+      sort_order: id,
+      created_at: '2026-10-21T00:00:00Z',
+      updated_at: '2026-10-21T00:00:00Z',
+      badge_count,
+    });
+
+    it('shows the count an app reports, capped at 99+', async () => {
+      server.use(
+        http.get('/api/v1/external-links/', () =>
+          HttpResponse.json([
+            link(1, 'Orders', 7),
+            link(2, 'Shop', 150, true),
+            link(3, 'Wiki', 0),
+            link(4, 'Docs', null),
+          ]),
+        ),
+      );
+      render(<Layout />);
+
+      const orders = await screen.findByTestId('external-link-badge-1');
+      expect(orders).toHaveTextContent('7');
+      expect(screen.getByTestId('external-link-badge-2')).toHaveTextContent('99+');
+      expect(screen.queryByTestId('external-link-badge-3')).not.toBeInTheDocument();
+      expect(screen.queryByTestId('external-link-badge-4')).not.toBeInTheDocument();
+    });
+  });
+
   describe('navigation', () => {
     it('has navigation items', async () => {
       render(<Layout />);

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

@@ -541,6 +541,10 @@ export const handlers = [
   http.get('/api/v1/printers/:id/slot-presets', () => HttpResponse.json([])),
   http.get('/api/v1/smart-plugs/by-printer/:id', () => HttpResponse.json([])),
   http.get('/api/v1/smart-plugs/by-printer/:id/scripts', () => HttpResponse.json([])),
+  // Asked by the bulk mocks above for each id: without them the fan-out fails
+  // and the client falls back to one request per printer.
+  http.get('/api/v1/ha-sensors/by-printer/:id/readings', () => HttpResponse.json([])),
+  http.get('/api/v1/queue/', () => HttpResponse.json([])),
   http.get('/api/v1/spoolbuddy/devices', () => HttpResponse.json([])),
   http.get('/api/v1/spoolman/inventory/filaments', () => HttpResponse.json([])),
   http.get('/api/v1/spoolman/spools/linked', () => HttpResponse.json([])),

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

@@ -10,6 +10,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
 import { waitFor } from '@testing-library/react';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
+import { batchesSettled } from '../../api/batch';
 import { render } from '../utils';
 import { PrintersPage } from '../../pages/PrintersPage';
 
@@ -82,6 +83,9 @@ describe('PrintersPage — batched per-printer reads', () => {
         expect(paths.some((p) => p.startsWith(`/api/v1/bulk/${route}?ids=1,2,3`))).toBe(true);
       }
     });
+    // A bulk request that fails falls back to one request per printer, sent
+    // after it: wait for those too, or they land after the check.
+    await batchesSettled();
     const perPrinter = paths.filter((p) =>
       /^\/api\/v1\/(printers\/\d+\/(slot-presets|ams-labels)|smart-plugs\/by-printer\/\d+|ha-sensors\/by-printer\/\d+)/.test(p),
     );

+ 24 - 0
frontend/src/__tests__/pages/PrintersPagePlateCheckWiring.test.tsx

@@ -193,6 +193,30 @@ describe('PrintersPage plate check: chamber light handling', () => {
     await waitFor(() => expect(sent).toEqual([light(true), check, light(false)]));
   });
 
+  it('Escape pressed the moment the result shows still closes it', async () => {
+    const user = userEvent.setup();
+    const sent = setup({ lightOn: false });
+    // Pressed from the DOM change itself: before React's passive effects of
+    // that render could run, as on a busy machine.
+    const pressed = { done: false };
+    const observer = new MutationObserver(() => {
+      if (!pressed.done && screen.queryByText('Build Plate Check')) {
+        pressed.done = true;
+        fireEvent.keyDown(window, { key: 'Escape' });
+      }
+    });
+    observer.observe(document.body, { childList: true, subtree: true });
+
+    try {
+      await openCheck(user);
+      await waitFor(() => expect(pressed.done).toBe(true), LIGHT_WAIT);
+      await waitFor(() => expect(screen.queryByText('Build Plate Check')).toBeNull());
+      await waitFor(() => expect(sent).toEqual([light(true), check, light(false)]));
+    } finally {
+      observer.disconnect();
+    }
+  });
+
   it('light already on: no chamber-light request on open or on close', async () => {
     const user = userEvent.setup();
     const sent = setup({ lightOn: true });

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

@@ -4299,6 +4299,8 @@ export interface ExternalLink {
   sort_order: number;
   created_at: string;
   updated_at: string;
+  /** Count reported by the app the link points to, while it keeps reporting. */
+  badge_count?: number | null;
 }
 
 export interface ExternalLinkCreate {

+ 27 - 18
frontend/src/components/Layout.tsx

@@ -241,6 +241,8 @@ export function Layout() {
   const { data: externalLinks } = useQuery({
     queryKey: ['external-links'],
     queryFn: api.getExternalLinks,
+    // Like the queue badge: apps behind the links report counts.
+    refetchInterval: 30_000,
   });
 
   // Fetch smart plugs to check for switchbar items
@@ -665,6 +667,29 @@ export function Layout() {
                 if (!link) return null;
 
                 const LinkIcon = link.custom_icon ? null : getIconByName(link.icon);
+                // A count the app behind the link reports (Bambuddy Orders' To do).
+                const linkBadge = link.badge_count ?? 0;
+                const linkIcon = (
+                  <div className="relative">
+                    {link.custom_icon ? (
+                      <img
+                        src={api.getExternalLinkIconUrl(link.id)}
+                        alt=""
+                        className="w-5 h-5 flex-shrink-0"
+                      />
+                    ) : (
+                      LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
+                    )}
+                    {linkBadge > 0 && (
+                      <span
+                        data-testid={`external-link-badge-${link.id}`}
+                        className="absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] px-1 flex items-center justify-center text-[10px] font-bold rounded-full bg-yellow-500 text-black"
+                      >
+                        {linkBadge > 99 ? '99+' : linkBadge}
+                      </span>
+                    )}
+                  </div>
+                );
                 return (
                   <li key={id}>
                     {link.open_in_new_tab ? (
@@ -675,15 +700,7 @@ export function Layout() {
                         className={`flex items-center ${isSidebarCompact || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white`}
                         title={!isSidebarCompact && !sidebarExpanded ? link.name : undefined}
                       >
-                        {link.custom_icon ? (
-                          <img
-                            src={api.getExternalLinkIconUrl(link.id)}
-                            alt=""
-                            className="w-5 h-5 flex-shrink-0"
-                          />
-                        ) : (
-                          LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
-                        )}
+                        {linkIcon}
                         {(isSidebarCompact || sidebarExpanded) && <span>{link.name}</span>}
                       </a>
                     ) : (
@@ -698,15 +715,7 @@ export function Layout() {
                         }
                         title={!isSidebarCompact && !sidebarExpanded ? link.name : undefined}
                       >
-                        {link.custom_icon ? (
-                          <img
-                            src={api.getExternalLinkIconUrl(link.id)}
-                            alt=""
-                            className="w-5 h-5 flex-shrink-0"
-                          />
-                        ) : (
-                          LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
-                        )}
+                        {linkIcon}
                         {(isSidebarCompact || sidebarExpanded) && <span>{link.name}</span>}
                       </NavLink>
                     )}

+ 1 - 1
frontend/src/i18n/locales/de.ts

@@ -2422,7 +2422,7 @@ export default {
     updateEnergyCostDescription: 'Erlaubt diesem Schlüssel, einen neuen Strompreis pro kWh an /settings/electricity-price zu senden. Nützlich für Home-Assistant-Automatisierungen mit dynamischen Tarifen (Tibber, Octopus usw.). Dies ist das einzige Einstellungsfeld, das per API-Schlüssel schreibbar ist.',
     energyCostBadge: 'Energie',
     sendNotifications: 'Benachrichtigungen senden',
-    sendNotificationsDescription: 'Erlaubt diesem Schlüssel, Nachrichten über die Benachrichtigungskanäle zu senden, die Nachrichten verbundener Apps annehmen (POST /notifications/app-message). Für Apps wie Bambuddy Orders. Sonst nichts: Der Schlüssel kann die Kanäle weder lesen noch ändern.',
+    sendNotificationsDescription: 'Erlaubt diesem Schlüssel, Nachrichten über die Benachrichtigungskanäle zu senden, die Nachrichten verbundener Apps annehmen (POST /notifications/app-message), und eine Zahl am Seitenleisten-Link anzuzeigen, der auf die App zeigt (PUT /external-links/badge). Für Apps wie Bambuddy Orders. Sonst nichts: Der Schlüssel kann die Kanäle und Links weder lesen noch ändern.',
     sendNotificationsBadge: 'Benachr.',
     legacyKey: 'Alt',
     legacyKeyTooltip: 'Wurde vor der nutzerbezogenen Eigentümerschaft erstellt; neu erstellen, um Cloud-Zugriff zu nutzen',

+ 1 - 1
frontend/src/i18n/locales/en.ts

@@ -2444,7 +2444,7 @@ export default {
     updateEnergyCostDescription: 'Allow this key to POST a new per-kWh electricity price to /settings/electricity-price. Useful for Home Assistant dynamic-tariff automations (Tibber, Octopus, etc.). This is the only settings field writable via API key.',
     energyCostBadge: 'Energy',
     sendNotifications: 'Send notifications',
-    sendNotificationsDescription: 'Allow this key to send messages through the notification channels that accept messages from connected apps (POST /notifications/app-message). For apps like Bambuddy Orders. Nothing else: the key can\'t read or change the channels.',
+    sendNotificationsDescription: 'Allow this key to send messages through the notification channels that accept messages from connected apps (POST /notifications/app-message), and to show a count on the sidebar link that points at the app (PUT /external-links/badge). For apps like Bambuddy Orders. Nothing else: the key can\'t read or change the channels or the links.',
     sendNotificationsBadge: 'Notify',
     legacyKey: 'Legacy',
     legacyKeyTooltip: 'Created before per-user ownership; recreate to use cloud access',

+ 1 - 1
frontend/src/i18n/locales/es.ts

@@ -2425,7 +2425,7 @@ export default {
     updateEnergyCostDescription: 'Permite que esta clave envíe por POST un nuevo precio de electricidad por kWh a /settings/electricity-price. Útil para las automatizaciones de tarifa dinámica de Home Assistant (Tibber, Octopus, etc.). Este es el único campo de ajustes que se puede escribir mediante una clave API.',
     energyCostBadge: 'Energía',
     sendNotifications: 'Enviar notificaciones',
-    sendNotificationsDescription: 'Permite a esta clave enviar mensajes a través de los canales de notificación que aceptan mensajes de aplicaciones conectadas (POST /notifications/app-message). Para aplicaciones como Bambuddy Orders. Nada más: la clave no puede leer ni cambiar los canales.',
+    sendNotificationsDescription: 'Permite a esta clave enviar mensajes a través de los canales de notificación que aceptan mensajes de aplicaciones conectadas (POST /notifications/app-message) y mostrar un número en el enlace de la barra lateral que apunta a la aplicación (PUT /external-links/badge). Para aplicaciones como Bambuddy Orders. Nada más: la clave no puede leer ni cambiar los canales ni los enlaces.',
     sendNotificationsBadge: 'Notif.',
     legacyKey: 'Heredada',
     legacyKeyTooltip: 'Creada antes de la propiedad por usuario; vuelva a crearla para usar el acceso a la nube',

+ 1 - 1
frontend/src/i18n/locales/fr.ts

@@ -2377,7 +2377,7 @@ export default {
     updateEnergyCostDescription: 'Autoriser cette clé à POSTer un nouveau tarif électrique par kWh sur /settings/electricity-price. Utile pour les automatisations Home Assistant à tarif dynamique (Tibber, Octopus, etc.). Il s\'agit du seul champ de paramètres modifiable via clé API.',
     energyCostBadge: 'Énergie',
     sendNotifications: 'Envoyer des notifications',
-    sendNotificationsDescription: 'Autorise cette clé à envoyer des messages via les canaux de notification qui acceptent les messages des applications connectées (POST /notifications/app-message). Pour des applications comme Bambuddy Orders. Rien d\'autre : la clé ne peut ni lire ni modifier les canaux.',
+    sendNotificationsDescription: 'Autorise cette clé à envoyer des messages via les canaux de notification qui acceptent les messages des applications connectées (POST /notifications/app-message), et à afficher un nombre sur le lien de la barre latérale qui pointe vers l\'application (PUT /external-links/badge). Pour des applications comme Bambuddy Orders. Rien d\'autre : la clé ne peut ni lire ni modifier les canaux ou les liens.',
     sendNotificationsBadge: 'Notif.',
     legacyKey: 'Hérité',
     legacyKeyTooltip: 'Créé avant la propriété par utilisateur ; recréez pour l\'accès cloud',

+ 1 - 1
frontend/src/i18n/locales/it.ts

@@ -2377,7 +2377,7 @@ export default {
     updateEnergyCostDescription: 'Consenti a questa chiave di inviare in POST un nuovo prezzo elettricità per kWh a /settings/electricity-price. Utile per automazioni Home Assistant a tariffa dinamica (Tibber, Octopus, ecc.). Questo è l\'unico campo di impostazioni scrivibile via API key.',
     energyCostBadge: 'Energia',
     sendNotifications: 'Invia notifiche',
-    sendNotificationsDescription: 'Consente a questa chiave di inviare messaggi tramite i canali di notifica che accettano messaggi dalle app collegate (POST /notifications/app-message). Per app come Bambuddy Orders. Nient\'altro: la chiave non può leggere né modificare i canali.',
+    sendNotificationsDescription: 'Consente a questa chiave di inviare messaggi tramite i canali di notifica che accettano messaggi dalle app collegate (POST /notifications/app-message) e di mostrare un numero sul link della barra laterale che punta all\'app (PUT /external-links/badge). Per app come Bambuddy Orders. Nient\'altro: la chiave non può leggere né modificare i canali o i link.',
     sendNotificationsBadge: 'Notif.',
     legacyKey: 'Legacy',
     legacyKeyTooltip: 'Creato prima della proprietà per utente; ricreare per accesso cloud',

+ 1 - 1
frontend/src/i18n/locales/ja.ts

@@ -2421,7 +2421,7 @@ export default {
     updateEnergyCostDescription: 'このキーが /settings/electricity-price に新しいkWhごとの電気料金をPOSTすることを許可。Home Assistantの動的料金自動化(Tibber、Octopusなど)に便利。これはAPIキーで書き込み可能な唯一の設定フィールドです。',
     energyCostBadge: '電力',
     sendNotifications: '通知の送信',
-    sendNotificationsDescription: 'このキーで、連携アプリからのメッセージを受け付ける通知チャネル経由でメッセージを送信できるようにします(POST /notifications/app-message)。Bambuddy Orders などのアプリ向けです。それ以外の権限はなく、チャネルの読み取りや変更はできません。',
+    sendNotificationsDescription: 'このキーで、連携アプリからのメッセージを受け付ける通知チャネル経由でメッセージを送信し(POST /notifications/app-message)、そのアプリを指すサイドバーのリンクに件数を表示できるようにします(PUT /external-links/badge)。Bambuddy Orders などのアプリ向けです。それ以外の権限はなく、チャネルやリンクの読み取りや変更はできません。',
     sendNotificationsBadge: '通知',
     legacyKey: 'レガシー',
     legacyKeyTooltip: 'ユーザー所有以前に作成 - クラウドアクセスには再作成が必要',

+ 1 - 1
frontend/src/i18n/locales/ko.ts

@@ -2998,7 +2998,7 @@ export default {
     updateEnergyCostDescription: '이 키가 /settings/electricity-price에 새 kWh당 전기 요금을 POST할 수 있도록 허용합니다. Home Assistant 동적 요금 자동화(Tibber, Octopus 등)에 유용합니다. API 키로 쓸 수 있는 유일한 설정 필드입니다.',
     energyCostBadge: '에너지',
     sendNotifications: '알림 보내기',
-    sendNotificationsDescription: '이 키가 연결된 앱의 메시지를 받는 알림 채널을 통해 메시지를 보낼 수 있도록 허용합니다(POST /notifications/app-message). Bambuddy Orders 같은 앱용입니다. 그 외에는 없으며, 키로 채널을 읽거나 변경할 수 없습니다.',
+    sendNotificationsDescription: '이 키가 연결된 앱의 메시지를 받는 알림 채널을 통해 메시지를 보내고(POST /notifications/app-message), 해당 앱을 가리키는 사이드바 링크에 숫자를 표시할 수 있도록 허용합니다(PUT /external-links/badge). Bambuddy Orders 같은 앱용입니다. 그 외에는 없으며, 키로 채널이나 링크를 읽거나 변경할 수 없습니다.',
     sendNotificationsBadge: '알림',
     passwordRequirements: '최소 8자, 대문자, 소문자, 숫자, 특수문자 각 1개 이상 포함',
 

+ 1 - 1
frontend/src/i18n/locales/nl.ts

@@ -2442,7 +2442,7 @@ export default {
     updateEnergyCostDescription: 'Sta toe dat deze sleutel via POST een nieuwe elektriciteitsprijs per kWh naar /settings/electricity-price stuurt. Handig voor Home Assistant-automatiseringen met dynamische tarieven (Tibber, Octopus, enz.). Dit is het enige instellingenveld dat via een API-sleutel schrijfbaar is.',
     energyCostBadge: 'Energie',
     sendNotifications: 'Meldingen versturen',
-    sendNotificationsDescription: 'Staat deze sleutel toe berichten te versturen via de meldingskanalen die berichten van gekoppelde apps accepteren (POST /notifications/app-message). Voor apps zoals Bambuddy Orders. Verder niets: de sleutel kan de kanalen niet lezen of wijzigen.',
+    sendNotificationsDescription: 'Staat deze sleutel toe berichten te versturen via de meldingskanalen die berichten van gekoppelde apps accepteren (POST /notifications/app-message) en een aantal te tonen op de zijbalklink die naar de app wijst (PUT /external-links/badge). Voor apps zoals Bambuddy Orders. Verder niets: de sleutel kan de kanalen en links niet lezen of wijzigen.',
     sendNotificationsBadge: 'Meldingen',
     legacyKey: 'Verouderd',
     legacyKeyTooltip: 'Aangemaakt vóór eigendom per gebruiker; maak opnieuw om cloudtoegang te gebruiken',

+ 1 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -2377,7 +2377,7 @@ export default {
     updateEnergyCostDescription: 'Permitir que esta chave envie POST de um novo preço por kWh para /settings/electricity-price. Útil para automações de tarifa dinâmica do Home Assistant (Tibber, Octopus, etc.). Este é o único campo de configuração gravável via API key.',
     energyCostBadge: 'Energia',
     sendNotifications: 'Enviar notificações',
-    sendNotificationsDescription: 'Permite que esta chave envie mensagens pelos canais de notificação que aceitam mensagens de aplicativos conectados (POST /notifications/app-message). Para aplicativos como o Bambuddy Orders. Nada além disso: a chave não pode ler nem alterar os canais.',
+    sendNotificationsDescription: 'Permite que esta chave envie mensagens pelos canais de notificação que aceitam mensagens de aplicativos conectados (POST /notifications/app-message) e mostre um número no link da barra lateral que aponta para o aplicativo (PUT /external-links/badge). Para aplicativos como o Bambuddy Orders. Nada além disso: a chave não pode ler nem alterar os canais ou os links.',
     sendNotificationsBadge: 'Notif.',
     legacyKey: 'Legado',
     legacyKeyTooltip: 'Criado antes da propriedade por usuário; recrie para acesso à nuvem',

+ 1 - 1
frontend/src/i18n/locales/ru.ts

@@ -2303,7 +2303,7 @@ export default {
     updateEnergyCostDescription: "Разрешить этому ключу отправлять новую цену за кВт⋅ч методом POST в /settings/electricity-price. Полезно для автоматизаций динамического тарифа Home Assistant (Tibber, Octopus и т. п.). Это единственный параметр настроек, доступный для записи через ключ API.",
     energyCostBadge: "Энергия",
     sendNotifications: 'Отправка уведомлений',
-    sendNotificationsDescription: 'Разрешает этому ключу отправлять сообщения через каналы уведомлений, принимающие сообщения подключённых приложений (POST /notifications/app-message). Для приложений вроде Bambuddy Orders. Больше ничего: ключ не может читать или изменять каналы.',
+    sendNotificationsDescription: 'Разрешает этому ключу отправлять сообщения через каналы уведомлений, принимающие сообщения подключённых приложений (POST /notifications/app-message), и показывать число на ссылке боковой панели, ведущей на приложение (PUT /external-links/badge). Для приложений вроде Bambuddy Orders. Больше ничего: ключ не может читать или изменять каналы и ссылки.',
     sendNotificationsBadge: 'Уведомл.',
     legacyKey: "Устаревший",
     legacyKeyTooltip: "Создан до появления привязки к пользователям; пересоздайте для доступа к облаку",

+ 1 - 1
frontend/src/i18n/locales/sv.ts

@@ -2441,7 +2441,7 @@ export default {
     updateEnergyCostDescription: 'Tillåt denna nyckel att POST:a ett nytt pris per kWh till /settings/electricity-price. Användbart för Home Assistants dynamiska tariffautomatiseringar (Tibber, Octopus, etc.). Detta är det enda inställningsfält som kan skrivas via API-nyckel.',
     energyCostBadge: 'Energi',
     sendNotifications: 'Skicka aviseringar',
-    sendNotificationsDescription: 'Tillåter nyckeln att skicka meddelanden via de aviseringskanaler som tar emot meddelanden från anslutna appar (POST /notifications/app-message). För appar som Bambuddy Orders. Inget annat: nyckeln kan inte läsa eller ändra kanalerna.',
+    sendNotificationsDescription: 'Tillåter nyckeln att skicka meddelanden via de aviseringskanaler som tar emot meddelanden från anslutna appar (POST /notifications/app-message) och att visa ett antal på sidofältslänken som pekar på appen (PUT /external-links/badge). För appar som Bambuddy Orders. Inget annat: nyckeln kan inte läsa eller ändra kanalerna eller länkarna.',
     sendNotificationsBadge: 'Avis.',
     legacyKey: 'Äldre nyckel',
     legacyKeyTooltip: 'Skapad före användarägande; skapa igen för att använda molnåtkomst',

+ 1 - 1
frontend/src/i18n/locales/tr.ts

@@ -2426,7 +2426,7 @@ export default {
     updateEnergyCostDescription: "Bu anahtarın /settings/electricity-price üzerine yeni bir kWh başına elektrik fiyatı POST etmesine izin ver. Home Assistant dinamik tarife otomasyonları (Tibber, Octopus, vb.) için kullanışlıdır. Bu, API anahtarıyla yazılabilen tek ayar alanıdır.",
     energyCostBadge: 'Enerji',
     sendNotifications: 'Bildirim gönder',
-    sendNotificationsDescription: 'Bu anahtarın, bağlı uygulamalardan gelen mesajları kabul eden bildirim kanalları üzerinden mesaj göndermesine izin verir (POST /notifications/app-message). Bambuddy Orders gibi uygulamalar için. Başka bir şey değil: anahtar kanalları okuyamaz veya değiştiremez.',
+    sendNotificationsDescription: 'Bu anahtarın, bağlı uygulamalardan gelen mesajları kabul eden bildirim kanalları üzerinden mesaj göndermesine (POST /notifications/app-message) ve uygulamayı gösteren kenar çubuğu bağlantısında bir sayı göstermesine (PUT /external-links/badge) izin verir. Bambuddy Orders gibi uygulamalar için. Başka bir şey değil: anahtar kanalları veya bağlantıları okuyamaz ya da değiştiremez.',
     sendNotificationsBadge: 'Bildirim',
     legacyKey: 'Eski',
     legacyKeyTooltip: 'Kullanıcı başına sahiplikten önce oluşturuldu; bulut erişimini kullanmak için yeniden oluşturun',

+ 1 - 1
frontend/src/i18n/locales/uk.ts

@@ -2440,7 +2440,7 @@ export default {
     updateEnergyCostDescription: "Дозволити цьому ключу надсилати нову ціну електроенергії за кВт·год до /settings/electricity-price. Це корисно для автоматизацій Home Assistant із динамічними тарифами (Tibber, Octopus тощо). Це єдине поле налаштувань, яке можна змінювати за допомогою ключа API.",
     energyCostBadge: "Енергія",
     sendNotifications: 'Надсилання сповіщень',
-    sendNotificationsDescription: 'Дозволяє цьому ключу надсилати повідомлення через канали сповіщень, які приймають повідомлення підключених застосунків (POST /notifications/app-message). Для застосунків на кшталт Bambuddy Orders. Більше нічого: ключ не може читати чи змінювати канали.',
+    sendNotificationsDescription: 'Дозволяє цьому ключу надсилати повідомлення через канали сповіщень, які приймають повідомлення підключених застосунків (POST /notifications/app-message), і показувати число на посиланні бічної панелі, що веде на застосунок (PUT /external-links/badge). Для застосунків на кшталт Bambuddy Orders. Більше нічого: ключ не може читати чи змінювати канали й посилання.',
     sendNotificationsBadge: 'Сповіщ.',
     legacyKey: "Застарілий",
     legacyKeyTooltip: "Створено до отримання права власності на користувача; повторно створити, щоб використовувати доступ до хмари",

+ 1 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -2422,7 +2422,7 @@ export default {
     updateEnergyCostDescription: '允许此密钥向 /settings/electricity-price POST 新的每千瓦时电价。适用于 Home Assistant 动态电价自动化(Tibber、Octopus 等)。这是唯一可通过 API 密钥写入的设置字段。',
     energyCostBadge: '能耗',
     sendNotifications: '发送通知',
-    sendNotificationsDescription: '允许此密钥通过接受已连接应用消息的通知渠道发送消息(POST /notifications/app-message)。适用于 Bambuddy Orders 等应用。仅此而已:该密钥无法读取或更改渠道。',
+    sendNotificationsDescription: '允许此密钥通过接受已连接应用消息的通知渠道发送消息(POST /notifications/app-message),并在指向该应用的侧边栏链接上显示数字(PUT /external-links/badge)。适用于 Bambuddy Orders 等应用。仅此而已:该密钥无法读取或更改渠道和链接。',
     sendNotificationsBadge: '通知',
     legacyKey: '传统',
     legacyKeyTooltip: '在按用户所有权之前创建;需重建以使用云端访问',

+ 1 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -2422,7 +2422,7 @@ export default {
     updateEnergyCostDescription: '允許此金鑰向 /settings/electricity-price POST 新的每千瓦時電價。適用於 Home Assistant 動態電價自動化(Tibber、Octopus 等)。這是唯一可透過 API 金鑰寫入的設定欄位。',
     energyCostBadge: '能耗',
     sendNotifications: '傳送通知',
-    sendNotificationsDescription: '允許此金鑰透過接受已連線應用程式訊息的通知管道傳送訊息(POST /notifications/app-message)。適用於 Bambuddy Orders 等應用程式。僅此而已:此金鑰無法讀取或變更管道。',
+    sendNotificationsDescription: '允許此金鑰透過接受已連線應用程式訊息的通知管道傳送訊息(POST /notifications/app-message),並在指向該應用程式的側邊欄連結上顯示數字(PUT /external-links/badge)。適用於 Bambuddy Orders 等應用程式。僅此而已:此金鑰無法讀取或變更管道和連結。',
     sendNotificationsBadge: '通知',
     legacyKey: '舊版',
     legacyKeyTooltip: '在按使用者所有權之前建立;需重建以使用雲端存取',

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

@@ -3369,8 +3369,11 @@ function PrinterCard({
     }
   };
 
-  // Close plate check modal on Escape key
-  useEffect(() => {
+  // Close plate check modal on Escape key. A layout effect, so the listener
+  // that knows about the result is in place as soon as the modal is shown: an
+  // Escape pressed before a passive effect ran met the listener from before,
+  // which ignored it.
+  useLayoutEffect(() => {
     const handleEscape = (e: KeyboardEvent) => {
       if (e.key === 'Escape' && plateCheckResult) {
         closePlateCheckModal();

Plik diff jest za duży
+ 0 - 0
static/assets/ArchivesPage-Cijsih6K.js


Plik diff jest za duży
+ 0 - 0
static/assets/CameraPage-Wnku0dqm.js


Plik diff jest za duży
+ 0 - 0
static/assets/FileManagerPage-CLYHqJkv.js


Plik diff jest za duży
+ 0 - 0
static/assets/InventoryPage-BasVhHe0.js


Plik diff jest za duży
+ 0 - 0
static/assets/Layout-ONJVQ_oD.js


Plik diff jest za duży
+ 0 - 0
static/assets/ModelSourcesPage-BvLs6J_U.js


Plik diff jest za duży
+ 0 - 0
static/assets/PhotoGalleryModal-btgMm3DW.js


Plik diff jest za duży
+ 0 - 0
static/assets/PrintersPage-BsUyKUN8.js


Plik diff jest za duży
+ 0 - 0
static/assets/SettingsPage-Beo2lGCx.js


Plik diff jest za duży
+ 0 - 0
static/assets/SliceModal-Cba5GYI7.js


Plik diff jest za duży
+ 1 - 1
static/assets/SpoolBuddySettingsPage-BeM5JwM3.js


Plik diff jest za duży
+ 0 - 0
static/assets/de-D39JI_8d.js


Plik diff jest za duży
+ 0 - 0
static/assets/es-BHL-6pV1.js


Plik diff jest za duży
+ 0 - 0
static/assets/es-QuPepRKs.js


Plik diff jest za duży
+ 0 - 0
static/assets/fr-C_28VCx0.js


Plik diff jest za duży
+ 0 - 0
static/assets/fr-CpEo28WL.js


Plik diff jest za duży
+ 0 - 0
static/assets/index-BNCUzO2G.js


Plik diff jest za duży
+ 0 - 0
static/assets/it-CFRvX4yY.js


Plik diff jest za duży
+ 0 - 0
static/assets/it-Dl6JGckA.js


Plik diff jest za duży
+ 0 - 0
static/assets/ja-vehaWIqv.js


Plik diff jest za duży
+ 0 - 0
static/assets/ko-CbTWUFFi.js


Plik diff jest za duży
+ 0 - 0
static/assets/locationSensorDefaults-BuT1z5wg.js


Plik diff jest za duży
+ 0 - 0
static/assets/nl-CixN_KdW.js


Plik diff jest za duży
+ 0 - 0
static/assets/nl-CyemxYPq.js


Plik diff jest za duży
+ 0 - 0
static/assets/pt-BR-DBfeh188.js


Plik diff jest za duży
+ 0 - 0
static/assets/pt-BR-sDjXgo3_.js


Plik diff jest za duży
+ 0 - 0
static/assets/ru-rVUUdG_R.js


Plik diff jest za duży
+ 0 - 0
static/assets/sv-BbgCZKfM.js


Plik diff jest za duży
+ 0 - 0
static/assets/sv-Cqpp_hoE.js


Plik diff jest za duży
+ 0 - 0
static/assets/tr-BXpm1KWk.js


Plik diff jest za duży
+ 0 - 0
static/assets/tr-Fokmo5P7.js


Plik diff jest za duży
+ 0 - 0
static/assets/uk-DO3--z4v.js


Plik diff jest za duży
+ 0 - 0
static/assets/zh-CN-D7IpC6DP.js


Plik diff jest za duży
+ 0 - 0
static/assets/zh-CN-D8DqooDL.js


Plik diff jest za duży
+ 0 - 0
static/assets/zh-TW-DARWgtcC.js


+ 2 - 2
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DkfTgrpJ.js"></script>
+    <script type="module" crossorigin src="/assets/index-BNCUzO2G.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-DSw2TdH4.js">
     <link rel="modulepreload" crossorigin href="/assets/react-dom-PmRWqOqI.js">
@@ -120,7 +120,7 @@
     <link rel="modulepreload" crossorigin href="/assets/circle-minus-CpiTIx_j.js">
     <link rel="modulepreload" crossorigin href="/assets/ConnectionDiagnostic-jD6ObGRm.js">
     <link rel="modulepreload" crossorigin href="/assets/plateCheck-fqh7_usj.js">
-    <link rel="modulepreload" crossorigin href="/assets/Layout-BE8fdF9U.js">
+    <link rel="modulepreload" crossorigin href="/assets/Layout-ONJVQ_oD.js">
     <link rel="stylesheet" crossorigin href="/assets/index-Cs9GKX3n.css">
   </head>
   <body>

+ 2 - 0
test_files_3029_1663994/stale.stl

@@ -0,0 +1,2 @@
+solid test
+endsolid test

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików