Jelajahi Sumber

Security hardening (maziggy/bambuddy-security #13)

maziggy 1 Minggu lalu
induk
melakukan
cfb5c54f19
100 mengubah file dengan 1079 tambahan dan 211 penghapusan
  1. 4 0
      backend/app/api/routes/api_keys.py
  2. 32 13
      backend/app/api/routes/notifications.py
  3. 48 0
      backend/app/api/routes/printers.py
  4. 17 0
      backend/app/api/routes/settings.py
  5. 2 0
      backend/app/api/routes/slicer_pipelines.py
  6. 10 8
      backend/app/api/routes/smart_plugs.py
  7. 49 31
      backend/app/api/routes/virtual_printers.py
  8. 88 26
      backend/app/core/database.py
  9. 87 0
      backend/app/core/printer_scope.py
  10. 8 0
      backend/app/core/sqlite_fk.py
  11. 4 2
      backend/app/models/notification.py
  12. 7 2
      backend/app/models/printer.py
  13. 4 2
      backend/app/models/smart_plug.py
  14. 5 0
      backend/app/services/github_restore.py
  15. 11 0
      backend/app/services/smart_plug_manager.py
  16. 14 4
      backend/tests/integration/test_obico_api.py
  17. 468 0
      backend/tests/integration/test_printer_delete_scopes.py
  18. 0 45
      backend/tests/unit/test_db_dialect.py
  19. 58 49
      backend/tests/unit/test_vp_delete_cleanup.py
  20. 93 1
      frontend/src/__tests__/components/FailureDetectionSettings.test.tsx
  21. 30 9
      frontend/src/components/FailureDetectionSettings.tsx
  22. 2 1
      frontend/src/i18n/locales/de.ts
  23. 2 1
      frontend/src/i18n/locales/en.ts
  24. 2 1
      frontend/src/i18n/locales/es.ts
  25. 2 1
      frontend/src/i18n/locales/fr.ts
  26. 2 1
      frontend/src/i18n/locales/it.ts
  27. 2 1
      frontend/src/i18n/locales/ja.ts
  28. 2 1
      frontend/src/i18n/locales/ko.ts
  29. 2 1
      frontend/src/i18n/locales/nl.ts
  30. 2 1
      frontend/src/i18n/locales/pt-BR.ts
  31. 2 1
      frontend/src/i18n/locales/ru.ts
  32. 2 1
      frontend/src/i18n/locales/sv.ts
  33. 2 1
      frontend/src/i18n/locales/tr.ts
  34. 2 1
      frontend/src/i18n/locales/uk.ts
  35. 2 1
      frontend/src/i18n/locales/zh-CN.ts
  36. 2 1
      frontend/src/i18n/locales/zh-TW.ts
  37. 8 0
      frontend/src/pages/PrintersPage.tsx
  38. 0 0
      static/assets/AdditionalSection-BiWcr8t-.js
  39. 0 0
      static/assets/AmsUnitCard-BqP8TlS_.js
  40. 0 0
      static/assets/ArchivesPage-DXiVYMDB.js
  41. 0 0
      static/assets/AssignToAmsModal-fG2qF5AK.js
  42. 0 0
      static/assets/CamWallPage-wODR7sNv.js
  43. 0 0
      static/assets/CameraDiagnoseModal-DNg5SJRE.js
  44. 0 0
      static/assets/CameraPage-fQ3AXYe3.js
  45. 0 0
      static/assets/CameraWall-DDF6DxOV.js
  46. 0 0
      static/assets/ColumnConfigModal-B_K6EHWV.js
  47. 0 0
      static/assets/ConfigureAmsSlotModal-dO4k_KKq.js
  48. 0 0
      static/assets/ConnectAuthorizePage-Bcjxtwf2.js
  49. 0 0
      static/assets/ContextMenu-BK0yhxxB.js
  50. 0 0
      static/assets/ExternalLinkPage-BbY3mS-F.js
  51. 0 1
      static/assets/FileManagerPage-DPjM1Bxu.js
  52. 0 0
      static/assets/FileUploadModal-CnGxZdU5.js
  53. 0 0
      static/assets/FinancePage-D70J3-En.js
  54. 0 0
      static/assets/GCodeViewerPage-Cc6svojb.js
  55. 0 0
      static/assets/GroupEditPage-BupGxnD8.js
  56. 0 0
      static/assets/ImagePreviewModal-vFJXoHIW.js
  57. 0 0
      static/assets/InventoryPage-CmzhHa0A.js
  58. 0 0
      static/assets/LibraryTrashPage-pChXBEnV.js
  59. 0 0
      static/assets/LoginPage-CKIxI_EP.js
  60. 0 0
      static/assets/MaintenancePage-Cil_uqXN.js
  61. 0 0
      static/assets/ModelSourcesPage-CQeHEPPm.js
  62. 0 0
      static/assets/NotificationsPage-BRDkDorp.js
  63. 0 1
      static/assets/PdfPreviewModal-YNm_y-Xf.js
  64. 0 0
      static/assets/PhotoGalleryModal-CtTNH5iJ.js
  65. 0 0
      static/assets/PrinterLocationsPage-kNqbZpyy.js
  66. 0 0
      static/assets/PrintersPage-DUj4FgER.js
  67. 0 0
      static/assets/ProfilesPage-DizkuEU_.js
  68. 0 0
      static/assets/ProjectDetailPage-DHQB8TsM.js
  69. 0 0
      static/assets/ProjectsPage-DyP5GAcR.js
  70. 0 0
      static/assets/QueuePage-m3Gi8n-g.js
  71. 0 0
      static/assets/RichTextEditor-B2hc_1Ni.js
  72. 0 0
      static/assets/SettingsPage-CflGzdcE.js
  73. 0 0
      static/assets/SetupPage-dFn8IThZ.js
  74. 0 0
      static/assets/SliceModal-Dma4cWn-.js
  75. 0 0
      static/assets/SpoolBuddyAmsPage-D2I328Of.js
  76. 0 0
      static/assets/SpoolBuddyCalibrationPage-DTRCWbxa.js
  77. 0 0
      static/assets/SpoolBuddyDashboard-DlbKB8kO.js
  78. 0 0
      static/assets/SpoolBuddyInventoryPage-gKr2Wetg.js
  79. 1 1
      static/assets/SpoolBuddySettingsPage-CT-TF6ZC.js
  80. 0 0
      static/assets/SpoolBuddyWriteTagPage-DX8ozw6r.js
  81. 0 0
      static/assets/SpreadsheetPreviewModal-CBtN2Lza.js
  82. 0 0
      static/assets/StatsPage-HJqcVOBc.js
  83. 0 0
      static/assets/StreamOverlayPage-C7WeayO1.js
  84. 0 0
      static/assets/SystemInfoPage-Bkkq9QOf.js
  85. 0 0
      static/assets/de-BBbbJU4T.js
  86. 0 0
      static/assets/de-d4f0ctP_.js
  87. 0 0
      static/assets/es-Bz63egNE.js
  88. 0 0
      static/assets/es-Dj4End9b.js
  89. 0 0
      static/assets/fr-BUDFE3iT.js
  90. 0 0
      static/assets/fr-CmF999mK.js
  91. 0 0
      static/assets/index-3KXgokKF.js
  92. 1 1
      static/assets/inventorySearch-nSMyBii7.js
  93. 0 0
      static/assets/it-BwpvLhyg.js
  94. 0 0
      static/assets/it-CFF2YIPs.js
  95. 0 0
      static/assets/ja-B-VXWExv.js
  96. 0 0
      static/assets/ja-qS_D-r1-.js
  97. 0 0
      static/assets/ko-B-kp19gj.js
  98. 0 0
      static/assets/ko-Bs5eRzkr.js
  99. 0 0
      static/assets/locationSensorDefaults-Cko4Ujn6.js
  100. 0 0
      static/assets/nl-855facJr.js

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

@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from backend.app.core.auth import RequirePermissionIfAuthEnabled, generate_api_key
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import ensure_printers_exist
 from backend.app.core.websocket import ws_manager
 from backend.app.models.api_key import APIKey
 from backend.app.models.user import User
@@ -53,6 +54,8 @@ async def create_api_key(
             detail="can_access_cloud requires authentication to be enabled (per-user cloud tokens)",
         )
 
+    await ensure_printers_exist(db, data.printer_ids)
+
     # Generate the key
     full_key, key_hash, key_prefix = generate_api_key()
 
@@ -168,6 +171,7 @@ async def update_api_key(
     if data.can_send_notifications is not None:
         api_key.can_send_notifications = data.can_send_notifications
     if data.printer_ids is not None:
+        await ensure_printers_exist(db, data.printer_ids)
         api_key.printer_ids = data.printer_ids
     if data.enabled is not None:
         api_key.enabled = data.enabled

+ 32 - 13
backend/app/api/routes/notifications.py

@@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from backend.app.core.auth import RequirePermissionIfAuthEnabled, ScopedCaller, require_notification_send
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import ensure_printers_exist
 from backend.app.models.notification import NotificationLog, NotificationProvider
 from backend.app.models.notification_lock_screen_widget import NotificationLockScreenWidget
 from backend.app.models.user import User
@@ -62,6 +63,33 @@ async def _resync_reaction_poller():
         logger.warning("Telegram reaction poller resync failed: %s", e)
 
 
+async def delete_provider(db: AsyncSession, provider: NotificationProvider) -> bool:
+    """Delete a provider (the caller commits, then calls after_providers_deleted).
+
+    A Bambuddy Notify provider is first retired and its live activities and
+    widgets queued for remote cleanup, while the rows that name them still
+    exist. Returns whether it was one.
+    """
+    notify = provider.provider_type == "notify"
+    if notify:
+        config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
+        # Retire this provider and capture owned IDs before FK cascade removes
+        # them. Queue remote cleanup; HTTP must never delay this request.
+        provider.enabled = False
+        await db.commit()
+        await notify_live_activities.schedule_cleanup(provider.id, config)
+        await notify_widgets.schedule_cleanup(provider.id, config)
+    await db.delete(provider)
+    return notify
+
+
+async def after_providers_deleted(*, notify: bool) -> None:
+    """What deleted providers leave to do once the delete is committed."""
+    if notify:
+        _notify_providers_changed()
+    await _resync_reaction_poller()
+
+
 def _provider_to_dict(provider: NotificationProvider) -> dict:
     """Convert a NotificationProvider model to a response dictionary."""
     return {
@@ -166,6 +194,7 @@ async def create_notification_provider(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
 ):
     """Create a new notification provider."""
+    await ensure_printers_exist(db, [provider_data.printer_id])
     if provider_data.provider_type.value == "notify":
         try:
             notify_credentials(provider_data.config)
@@ -563,6 +592,7 @@ async def update_notification_provider(
 
     # Update only provided fields
     update_dict = update_data.model_dump(exclude_unset=True)
+    await ensure_printers_exist(db, [update_dict.get("printer_id")])
 
     effective_type = update_dict.get("provider_type") or provider.provider_type
     effective_type = getattr(effective_type, "value", effective_type)
@@ -641,22 +671,11 @@ async def delete_notification_provider(
         raise HTTPException(status_code=404, detail="Notification provider not found")
 
     name = provider.name
-    if provider.provider_type == "notify":
-        config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
-        # Retire this provider and capture owned IDs before FK cascade removes
-        # them. Queue remote cleanup; HTTP must never delay this request.
-        provider.enabled = False
-        await db.commit()
-        await notify_live_activities.schedule_cleanup(provider_id, config)
-        await notify_widgets.schedule_cleanup(provider_id, config)
-    await db.delete(provider)
+    notify = await delete_provider(db, provider)
     await db.commit()
-
-    if provider.provider_type == "notify":
-        _notify_providers_changed()
+    await after_providers_deleted(notify=notify)
 
     logger.info("Deleted notification provider: %s", name)
-    await _resync_reaction_poller()
 
     return {"message": f"Notification provider '{name}' deleted"}
 

+ 48 - 0
backend/app/api/routes/printers.py

@@ -495,6 +495,19 @@ async def delete_printer(
                 f"currently printing. Stop the print first, or keep the archives.",
             )
 
+    # What was limited to this printer goes with it, rather than reaching every
+    # printer (a provider's NULL printer means all) or, on SQLite, whichever
+    # printer is given this id next (an API key's list keeps the id). Providers
+    # first: retiring a Bambuddy Notify one commits.
+    from backend.app.api.routes.notifications import after_providers_deleted, delete_provider
+    from backend.app.core.printer_scope import drop_printers_from_api_keys, drop_printers_from_obico
+    from backend.app.models.notification import NotificationProvider
+
+    notify = False
+    scoped = await db.execute(select(NotificationProvider).where(NotificationProvider.printer_id == printer_id))
+    for provider in scoped.scalars().all():
+        notify = await delete_provider(db, provider) or notify
+
     printer_manager.disconnect_printer(printer_id)
 
     if delete_archives:
@@ -528,8 +541,43 @@ async def delete_printer(
         )
         await db.execute(sql_delete(PrinterMaintenance).where(PrinterMaintenance.printer_id == printer_id))
 
+    await drop_printers_from_api_keys(db, [printer_id])
+    await drop_printers_from_obico(db, [printer_id])
+    # A virtual printer aimed at it goes too: without a target a queue-mode one
+    # takes jobs for any printer of its model (deleted below, with its plugs).
+    from backend.app.api.routes.virtual_printers import drop_virtual_printer, remove_upload_dir
+    from backend.app.models.virtual_printer import VirtualPrinter
+    from backend.app.services.virtual_printer import virtual_printer_manager
+
+    aimed = (
+        (await db.execute(select(VirtualPrinter.id).where(VirtualPrinter.target_printer_id == printer_id)))
+        .scalars()
+        .all()
+    )
+    # Its smart plugs too: without a printer a plug is everyone's, and users and
+    # keys limited to other printers could switch what it still powers.
+    from backend.app.api.routes.smart_plugs import delete_plug
+    from backend.app.models.smart_plug import SmartPlug
+
+    for plug in (await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))).scalars().all():
+        await delete_plug(db, plug)
+    upload_dirs = []
+    for vp_id in aimed:
+        await virtual_printer_manager.remove_instance(vp_id)
+        upload_dirs.append(await drop_virtual_printer(db, vp_id))
+
     await db.delete(printer)
     await db.commit()
+    await after_providers_deleted(notify=notify)
+    for upload_dir in upload_dirs:
+        remove_upload_dir(upload_dir)
+    if aimed:
+        try:
+            await virtual_printer_manager.sync_from_db()
+        except Exception as e:
+            logger.error("Failed to sync virtual printers after printer delete: %s", e)
+    # Open connections worked out their printers at connect.
+    await ws_manager.refresh_printer_scopes()
 
     return {"status": "deleted", "archives_deleted": delete_archives}
 

+ 17 - 0
backend/app/api/routes/settings.py

@@ -1,4 +1,5 @@
 import io
+import json
 import logging
 import os
 import zipfile
@@ -348,6 +349,22 @@ async def update_settings(
     if null_keys:
         raise HTTPException(status_code=422, detail=f"These settings cannot be null: {', '.join(null_keys)}")
 
+    # Like an API key's or a provider's printers: a page still open from before
+    # a printer delete would save the deleted id, which SQLite gives to the next
+    # printer created (the validator has already checked it is a list of ints).
+    # Only ids not stored yet are checked: the page sends the whole list, and an
+    # id already there must not block every save.
+    if update_data.get("obico_enabled_printers"):
+        from backend.app.core.printer_scope import ensure_printers_exist
+
+        try:
+            stored = json.loads(await get_setting(db, "obico_enabled_printers") or "[]")
+        except ValueError:
+            stored = []
+        stored = set(stored) if isinstance(stored, list) else set()
+        added = [i for i in json.loads(update_data["obico_enabled_printers"]) if i not in stored]
+        await ensure_printers_exist(db, added)
+
     # Safety refusals on disabling local login (#1589). Two failure modes
     # would otherwise lock everyone out of the install:
     #   1. No enabled OIDC provider exists — nobody could authenticate.

+ 2 - 0
backend/app/api/routes/slicer_pipelines.py

@@ -17,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import ensure_printers_exist
 from backend.app.models.slicer_pipeline import SlicerPipeline
 from backend.app.models.user import User
 from backend.app.schemas.slicer import PresetRef
@@ -165,6 +166,7 @@ async def update_pipeline(
         if data.target_printer_id == 0:
             row.target_printer_id = None
         else:
+            await ensure_printers_exist(db, [data.target_printer_id])
             row.target_printer_id = data.target_printer_id
     # PR C — class targeting + fanout strategy. Empty string from the frontend
     # also clears the class (radio toggled away).

+ 10 - 8
backend/app/api/routes/smart_plugs.py

@@ -42,7 +42,7 @@ from backend.app.services.mqtt_smart_plug import subscribe_plug_to_mqtt
 from backend.app.services.plug_energy_history import fill_derived_energy
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.rest_smart_plug import rest_smart_plug_service
-from backend.app.services.smart_plug_manager import publish_plug_state
+from backend.app.services.smart_plug_manager import publish_plug_state, smart_plug_manager
 from backend.app.services.tasmota import tasmota_service
 from backend.app.utils.local_time import utcnow_naive
 
@@ -595,6 +595,14 @@ async def update_smart_plug(
     return plug
 
 
+async def delete_plug(db: AsyncSession, plug: SmartPlug) -> None:
+    """Delete a smart plug (the caller commits). An MQTT plug stops listening first."""
+    smart_plug_manager.forget_plug(plug.id)
+    if plug.plug_type == "mqtt":
+        mqtt_relay.smart_plug_service.unsubscribe(plug.id)
+    await db.delete(plug)
+
+
 @router.delete("/{plug_id}")
 async def delete_smart_plug(
     plug_id: int,
@@ -609,13 +617,7 @@ async def delete_smart_plug(
         raise HTTPException(404, "Smart plug not found")
 
     plug_name = plug.name
-    plug_type = plug.plug_type
-
-    # Unsubscribe MQTT plug before deletion
-    if plug_type == "mqtt":
-        mqtt_relay.smart_plug_service.unsubscribe(plug_id)
-
-    await db.delete(plug)
+    await delete_plug(db, plug)
     await db.commit()
 
     logger.info("Deleted smart plug '%s'", plug_name)

+ 49 - 31
backend/app/api/routes/virtual_printers.py

@@ -1,9 +1,11 @@
 import logging
+import os
+from pathlib import Path
 
 from fastapi import APIRouter, Depends
 from fastapi.responses import JSONResponse
 from pydantic import BaseModel
-from sqlalchemy import select
+from sqlalchemy import select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
@@ -536,6 +538,50 @@ async def update_virtual_printer(
     return await _vp_to_dict(vp, db, status)
 
 
+async def drop_virtual_printer(db: AsyncSession, vp_id: int) -> Path:
+    """Delete a virtual printer's row and discard its pending uploads.
+
+    Its instance must be stopped first, if one runs. The caller commits, then
+    calls remove_upload_dir with what this returns. *db* is a session or a
+    connection.
+    """
+    from sqlalchemy import delete as sql_delete
+
+    from backend.app.models.pending_upload import PendingUpload
+    from backend.app.models.virtual_printer import VirtualPrinter
+    from backend.app.services.virtual_printer import virtual_printer_manager
+
+    upload_dir = virtual_printer_manager._base_dir / "uploads" / str(vp_id)
+    # Mark any PendingUpload rows that referenced this VP's upload_dir as
+    # discarded — without this the rows live on as phantom entries in
+    # /pending-uploads/ pointing at file paths that no longer exist, and
+    # the user only learns they're orphaned by trying to archive one and
+    # getting a flip-to-discarded on file-missing.
+    try:
+        await db.execute(
+            update(PendingUpload)
+            # The folder, not every path that starts with its name (uploads/12 for 1).
+            .where(PendingUpload.file_path.startswith(f"{upload_dir}{os.sep}", autoescape=True))
+            .values(status="discarded")
+        )
+    except Exception as e:
+        logger.error("Failed to discard orphan PendingUpload rows for VP %d: %s", vp_id, e)
+    await db.execute(sql_delete(VirtualPrinter).where(VirtualPrinter.id == vp_id))
+    return upload_dir
+
+
+def remove_upload_dir(upload_dir: Path) -> None:
+    """Remove a deleted virtual printer's upload directory.
+
+    After the DB commit succeeds, so a crash between commit and rmtree only
+    leaves orphan files (vs orphan rows pointing at a now-missing tree).
+    """
+    if upload_dir.exists():
+        import shutil
+
+        shutil.rmtree(upload_dir, ignore_errors=True)
+
+
 @router.delete("/{vp_id}")
 async def delete_virtual_printer(
     vp_id: int,
@@ -543,8 +589,6 @@ async def delete_virtual_printer(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Delete a virtual printer."""
-    from sqlalchemy import delete as sql_delete
-
     from backend.app.models.virtual_printer import VirtualPrinter
     from backend.app.services.virtual_printer import virtual_printer_manager
 
@@ -557,35 +601,9 @@ async def delete_virtual_printer(
 
     # Stop instance if running
     await virtual_printer_manager.remove_instance(vp_id)
-
-    # Mark any PendingUpload rows that referenced this VP's upload_dir as
-    # discarded — without this the rows live on as phantom entries in
-    # /pending-uploads/ pointing at file paths that no longer exist, and
-    # the user only learns they're orphaned by trying to archive one and
-    # getting a flip-to-discarded on file-missing.
-    upload_prefix = str(virtual_printer_manager._base_dir / "uploads" / str(vp_id))
-    try:
-        from backend.app.models.pending_upload import PendingUpload
-
-        stale = await db.execute(select(PendingUpload).where(PendingUpload.file_path.startswith(upload_prefix)))
-        for pending in stale.scalars().all():
-            pending.status = "discarded"
-        await db.flush()
-    except Exception as e:
-        logger.error("Failed to discard orphan PendingUpload rows for VP %d: %s", vp_id, e)
-
-    # Delete from DB
-    await db.execute(sql_delete(VirtualPrinter).where(VirtualPrinter.id == vp_id))
+    upload_dir = await drop_virtual_printer(db, vp_id)
     await db.commit()
-
-    # Remove the on-disk upload directory after the DB commit succeeds, so
-    # a crash between commit and rmtree only leaves orphan files (vs orphan
-    # rows pointing at a now-missing tree).
-    upload_dir = virtual_printer_manager._base_dir / "uploads" / str(vp_id)
-    if upload_dir.exists():
-        import shutil
-
-        shutil.rmtree(upload_dir, ignore_errors=True)
+    remove_upload_dir(upload_dir)
 
     logger.info("Deleted virtual printer: %s (id=%d)", vp_name, vp_id)
 

+ 88 - 26
backend/app/core/database.py

@@ -332,6 +332,38 @@ def load_all_models():
     return Base.metadata
 
 
+async def _drop_deleted_printers_from_limits(conn) -> None:
+    """Take printers deleted before deletes did so out of API keys, Obico and virtual printers (both databases).
+
+    SQLite gives a deleted printer's id to the next printer created. So does
+    PostgreSQL after a SQLite backup is imported: the sequence starts after the
+    highest id left. And a stale id would make every save of the list it is in
+    fail the check for unknown printers.
+    """
+    from sqlalchemy import exists, select
+
+    from backend.app.api.routes.virtual_printers import drop_virtual_printer
+    from backend.app.core.printer_scope import drop_printers_from_api_keys, drop_printers_from_obico
+    from backend.app.models.printer import Printer
+    from backend.app.models.virtual_printer import VirtualPrinter
+
+    await drop_printers_from_api_keys(conn)
+    await drop_printers_from_obico(conn)
+
+    aimed_at_nothing = (
+        await conn.execute(
+            select(VirtualPrinter.id).where(
+                VirtualPrinter.target_printer_id.is_not(None),
+                ~exists().where(Printer.id == VirtualPrinter.target_printer_id),
+            )
+        )
+    ).scalars()
+    for vp_id in aimed_at_nothing.all():
+        # Not running yet at start. Its upload directory stays, as after a
+        # crash between a delete's commit and the directory's removal.
+        await drop_virtual_printer(conn, vp_id)
+
+
 async def _repair_sqlite_orphans(conn) -> None:
     """Repair rows that earlier deletes left pointing at a row that is gone (SQLite only).
 
@@ -462,6 +494,7 @@ async def init_db():
         # SQLite applies no ON DELETE rules (foreign keys off): repair rows that
         # earlier deletes left pointing at nothing. Not a migration.
         await _repair_sqlite_orphans(conn)
+        await _drop_deleted_printers_from_limits(conn)
 
     # Re-encrypt any legacy plaintext OIDC client_secret / TOTP secret rows
     # that exist from before the encryption key was configured.
@@ -867,16 +900,6 @@ async def _api_keys_column_exists(conn, column_name: str) -> bool:
     return result.scalar_one_or_none() is not None
 
 
-async def _migrate_normalize_printer_ids(conn) -> None:
-    from sqlalchemy import text
-
-    async with conn.begin_nested():
-        if is_sqlite():
-            await conn.execute(text("UPDATE api_keys SET printer_ids = NULL WHERE printer_ids = '[]'"))
-        else:
-            await conn.execute(text("UPDATE api_keys SET printer_ids = NULL WHERE printer_ids::text = '[]'"))
-
-
 async def _migrate_scope_force_color_overrides_to_plate(conn) -> None:
     """Re-scope queue items that carry another plate's filament overrides (#2551).
 
@@ -3361,13 +3384,20 @@ async def _run_migrations(conn):
     except (OperationalError, ProgrammingError):
         pass  # Already applied
 
-    # Migration: Migrate single virtual printer key-value settings to virtual_printers table
+    # Migration: Migrate single virtual printer key-value settings to virtual_printers table.
+    # Once: the settings stay (the virtual printer settings still write some), so
+    # with no virtual printers left this rebuilt one at every start, aimed at the
+    # old target, which SQLite may have given to another printer since.
+    vp_flag = "_migrate_single_virtual_printer_done"
     try:
         async with conn.begin_nested():
+            already = (
+                await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": vp_flag})
+            ).scalar_one_or_none()
             result = await conn.execute(text("SELECT COUNT(*) FROM virtual_printers"))
             count = result.scalar() or 0
 
-            if count == 0:
+            if count == 0 and not already:
                 result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_enabled'"))
                 row = result.fetchone()
                 if row:
@@ -3430,6 +3460,10 @@ async def _run_migrations(conn):
                             "remote_iface": old_remote_iface,
                         },
                     )
+            if not already:
+                await conn.execute(
+                    text('INSERT INTO settings ("key", value) VALUES (:k, :v)'), {"k": vp_flag, "v": "true"}
+                )
     except (OperationalError, ProgrammingError, IntegrityError):
         pass  # Table may not exist yet on first run, or columns have different constraints
 
@@ -3857,11 +3891,10 @@ async def _run_migrations(conn):
         except (OperationalError, ProgrammingError):
             pass  # Already applied
 
-    # Migration: Normalize empty printer_ids [] to NULL (global access) on API keys
-    # Previously both None and [] meant "all printers"; now [] means "no printers"
-    # PostgreSQL stores printer_ids as JSONB; comparing JSONB to a string literal fails
-    # with "operator does not exist: jsonb = unknown" — cast the literal to jsonb explicitly.
-    await _migrate_normalize_printer_ids(conn)
+    # (An API key's printer_ids [] used to be turned into NULL here at every start,
+    # from when both meant all printers. [] means none now, so that made a key
+    # limited to no printer, such as one whose printers were all deleted, reach
+    # every printer after a restart. Old [] were converted long ago.)
 
     # Migration: Add auth_source column to users for LDAP support (#794)
     await _safe_execute(conn, "ALTER TABLE users ADD COLUMN auth_source VARCHAR(20) DEFAULT 'local' NOT NULL")
@@ -5519,6 +5552,7 @@ async def _run_migrations(conn):
     await _migrate_auth_datetimes_to_timestamp(conn)
     await _migrate_add_missing_set_null_fks(conn)
     await _migrate_wallet_transactions_keep_deleted_users(conn)
+    await _migrate_notification_providers_go_with_their_printer(conn)
     await _migrate_pg_column_types(conn)
     await _migrate_pg_columns_to_model(conn)
 
@@ -5755,21 +5789,49 @@ async def _migrate_wallet_transactions_keep_deleted_users(conn) -> None:
         return
 
     await _safe_execute(conn, "ALTER TABLE wallet_transactions ALTER COLUMN user_id DROP NOT NULL")
-    cascading = await conn.execute(
+    await _pg_set_fk_ondelete(conn, "wallet_transactions", "user_id", "users", "SET NULL")
+
+
+async def _migrate_notification_providers_go_with_their_printer(conn) -> None:
+    """notification_providers / smart_plugs.printer_id: ON DELETE CASCADE, as the models say (PostgreSQL).
+
+    It was SET NULL, and a provider without a printer is one for all printers:
+    deleting the printer a provider was limited to sent it every printer's
+    events. SQLite doesn't enforce the rule; the printer delete route removes
+    the providers on both.
+    """
+    if not is_sqlite():
+        await _pg_set_fk_ondelete(conn, "notification_providers", "printer_id", "printers", "CASCADE")
+        # Likewise a smart plug: without a printer it is everyone's to switch.
+        await _pg_set_fk_ondelete(conn, "smart_plugs", "printer_id", "printers", "CASCADE")
+
+
+_PG_ON_DELETE_CODES = {"CASCADE": "c", "SET NULL": "n"}
+
+
+async def _pg_set_fk_ondelete(conn, table: str, column: str, parent: str, ondelete: str) -> None:
+    """Give the single-column foreign key from *table*.*column* to *parent* the ON DELETE *ondelete*.
+
+    The constraint is replaced under its own name, and only where its rule differs.
+    """
+    from sqlalchemy import text
+
+    differing = await conn.execute(
         text(
             "SELECT c.conname FROM pg_constraint c "
             "JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY (c.conkey) "
-            "WHERE c.contype = 'f' AND c.conrelid = CAST('wallet_transactions' AS regclass) "
-            "AND c.confrelid = CAST('users' AS regclass) AND array_length(c.conkey, 1) = 1 "
-            "AND a.attname = 'user_id' AND c.confdeltype <> 'n'"
-        )
+            "WHERE c.contype = 'f' AND c.conrelid = CAST(:t AS regclass) "
+            "AND c.confrelid = CAST(:p AS regclass) AND array_length(c.conkey, 1) = 1 "
+            "AND a.attname = :c AND CAST(c.confdeltype AS text) <> :code"
+        ),
+        {"t": table, "p": parent, "c": column, "code": _PG_ON_DELETE_CODES[ondelete]},
     )
-    for name in cascading.scalars().all():
-        await conn.execute(text(f'ALTER TABLE wallet_transactions DROP CONSTRAINT "{name}"'))
+    for name in differing.scalars().all():
+        await conn.execute(text(f'ALTER TABLE {table} DROP CONSTRAINT "{name}"'))
         await conn.execute(
             text(
-                f'ALTER TABLE wallet_transactions ADD CONSTRAINT "{name}" '
-                "FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL"
+                f'ALTER TABLE {table} ADD CONSTRAINT "{name}" '
+                f"FOREIGN KEY ({column}) REFERENCES {parent}(id) ON DELETE {ondelete}"
             )
         )
 

+ 87 - 0
backend/app/core/printer_scope.py

@@ -189,3 +189,90 @@ def api_key_own_scope(api_key) -> PrinterScope:
     if api_key.printer_ids is None:
         return ALL_PRINTERS
     return PrinterScope(frozenset(int(pid) for pid in api_key.printer_ids))
+
+
+async def drop_printers_from_api_keys(db, deleted: Iterable[int] | None = None) -> None:
+    """Take deleted printers out of the API keys limited to them.
+
+    *deleted*: the printers being deleted, or None for every id that no longer
+    has a printer (the start-up repair). SQLite gives a freed id to the next
+    printer created, so a stale id would hand the key that printer. A key whose
+    last printer goes keeps an empty list, which reaches no printer; None would
+    reach all of them. *db* is a session or a connection.
+    """
+    from sqlalchemy import update
+
+    from backend.app.models.api_key import APIKey
+    from backend.app.models.printer import Printer
+
+    if deleted is None:
+        existing = set((await db.execute(select(Printer.id))).scalars().all())
+
+        def keeps(printer_id: int) -> bool:
+            return printer_id in existing
+    else:
+        gone = set(deleted)
+
+        def keeps(printer_id: int) -> bool:
+            return printer_id not in gone
+
+    for key_id, printer_ids in (await db.execute(select(APIKey.id, APIKey.printer_ids))).all():
+        if not isinstance(printer_ids, list):
+            continue  # all printers
+        kept = [printer_id for printer_id in printer_ids if keeps(printer_id)]
+        if kept != printer_ids:
+            await db.execute(update(APIKey).where(APIKey.id == key_id).values(printer_ids=kept))
+
+
+async def drop_printers_from_obico(db, deleted: Iterable[int] | None = None) -> None:
+    """Take deleted printers out of the printers Obico detection watches.
+
+    As drop_printers_from_api_keys: SQLite would enrol the next printer given a
+    stale id, sending its camera to the ML server and pausing its prints. An
+    empty or missing list means all printers and is left as it is; a list whose
+    last printer goes becomes "[]", which watches none.
+    """
+    import json
+
+    from sqlalchemy import update
+
+    from backend.app.models.printer import Printer
+    from backend.app.models.settings import Settings
+
+    key = "obico_enabled_printers"
+    raw = (await db.execute(select(Settings.value).where(Settings.key == key))).scalar()
+    if not raw:
+        return
+    try:
+        printer_ids = json.loads(raw)
+    except ValueError:
+        return
+    if not isinstance(printer_ids, list):
+        return
+    if deleted is None:
+        existing = set((await db.execute(select(Printer.id))).scalars().all())
+        kept = [printer_id for printer_id in printer_ids if printer_id in existing]
+    else:
+        gone = set(deleted)
+        kept = [printer_id for printer_id in printer_ids if printer_id not in gone]
+    if kept != printer_ids:
+        # Written as the settings page writes it ("[1,3]"); "[1, 3]" reads there
+        # as an unsaved change and saves itself again.
+        value = json.dumps(kept, separators=(",", ":"))
+        await db.execute(update(Settings).where(Settings.key == key).values(value=value))
+
+
+async def ensure_printers_exist(db: AsyncSession, printer_ids: Iterable[int | None] | None) -> None:
+    """400 for an id with no printer, before it is stored as a limit.
+
+    SQLite enforces no foreign keys and gives a freed id to the next printer
+    created, which a stored stale id would hand whatever it limits.
+    """
+    from backend.app.models.printer import Printer
+
+    wanted = {printer_id for printer_id in printer_ids or () if printer_id is not None}
+    if not wanted:
+        return
+    found = set((await db.execute(select(Printer.id).where(Printer.id.in_(wanted)))).scalars().all())
+    if missing := sorted(wanted - found):
+        raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Unknown printer: {', '.join(map(str, missing))}")

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

@@ -78,6 +78,14 @@ APPLIED: frozenset[tuple[str, str]] = frozenset(
         ("print_batches", "project_id"),
         ("print_queue", "project_id"),
         ("project_bom_items", "project_id"),
+        # printers: what the next printer given the id would otherwise take
+        # over. Only these: providers, plugs and virtual printers go with the
+        # printer in its delete route, and the rest is decided separately.
+        ("pipeline_jobs", "assigned_printer_id"),
+        ("slicer_pipelines", "target_printer_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"),
         # users: everything a user owns goes with them or loses its owner, or a
         # new user given the freed id inherits it (wallet, cost centres, private
         # folders). Without an owner, folders and private cost centres are left

+ 4 - 2
backend/app/models/notification.py

@@ -190,9 +190,11 @@ class NotificationProvider(Base):
     daily_digest_enabled: Mapped[bool | None] = mapped_column(Boolean, default=False)
     daily_digest_time: Mapped[str | None] = mapped_column(String(5), nullable=True)  # HH:MM format, e.g., "08:00"
 
-    # Optional: Link to specific printer (NULL = all printers)
+    # Optional: Link to specific printer (NULL = all printers). CASCADE, not SET
+    # NULL: a provider limited to a deleted printer must not become one for all
+    # of them. The printer delete route removes it first (remote cleanup).
     printer_id: Mapped[int | None] = mapped_column(
-        Integer, ForeignKey("printers.id", ondelete="SET NULL"), nullable=True
+        Integer, ForeignKey("printers.id", ondelete="CASCADE"), nullable=True
     )
 
     # Status tracking

+ 7 - 2
backend/app/models/printer.py

@@ -57,8 +57,13 @@ class Printer(Base):
 
     # Relationships
     archives: Mapped[list["PrintArchive"]] = relationship(back_populates="printer", cascade="all, delete-orphan")
-    smart_plugs: Mapped[list["SmartPlug"]] = relationship(back_populates="printer")
-    notification_providers: Mapped[list["NotificationProvider"]] = relationship(back_populates="printer")
+    # Deleted with the printer, as providers: unassigned plugs are everyone's.
+    smart_plugs: Mapped[list["SmartPlug"]] = relationship(back_populates="printer", cascade="all, delete-orphan")
+    # Deleted with the printer: setting their printer_id to NULL (the default)
+    # would make them providers for all printers.
+    notification_providers: Mapped[list["NotificationProvider"]] = relationship(
+        back_populates="printer", cascade="all, delete-orphan"
+    )
     maintenance_items: Mapped[list["PrinterMaintenance"]] = relationship(
         back_populates="printer", cascade="all, delete-orphan"
     )

+ 4 - 2
backend/app/models/smart_plug.py

@@ -80,8 +80,10 @@ class SmartPlug(Base):
     rest_energy_total_path: Mapped[str | None] = mapped_column(String(200), nullable=True)
     rest_energy_total_multiplier: Mapped[float] = mapped_column(Float, server_default="1.0")
 
-    # Link to printer (multiple plugs/scripts can be linked to one printer)
-    printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
+    # Link to printer (multiple plugs/scripts can be linked to one printer).
+    # CASCADE, not SET NULL: a plug without a printer is everyone's, so one left
+    # by a deleted printer would open the hardware it powers to every user.
+    printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"), nullable=True)
 
     # Whether this plug actually feeds the printer's own power (#2629). The
     # printer link is also used for accessories that merely follow the print

+ 5 - 0
backend/app/services/github_restore.py

@@ -44,6 +44,7 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.database import async_session
+from backend.app.core.printer_scope import drop_printers_from_obico
 from backend.app.models.archive import PrintArchive
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
 from backend.app.models.printer import Printer
@@ -1609,6 +1610,10 @@ class GitHubRestoreService:
             if keys_written is not None:
                 keys_written.add(key)
 
+        # A backup taken before a printer delete still lists it, and SQLite gives
+        # its id to the next printer created.
+        await drop_printers_from_obico(db)
+
         if plan.blocked:
             tally.note(
                 "settingsCredentialsSkipped",

+ 11 - 0
backend/app/services/smart_plug_manager.py

@@ -818,6 +818,17 @@ class SmartPlugManager:
             # Clear pending state in database
             spawn_background_task(self._mark_auto_off_pending(plug_id, False), name=f"plug-auto-off-pending-{plug_id}")
 
+    def forget_plug(self, plug_id: int) -> None:
+        """Cancel a deleted plug's pending turn-off.
+
+        The task holds the plug's address and printer id, so it would still switch
+        the outlet, and on SQLite read the next printer given the id. Nothing is
+        written: the row is gone, and SQLite gives its id to the next plug.
+        """
+        task = self._pending_off.pop(plug_id, None)
+        if task is not None:
+            task.cancel()
+
     def cancel_all_pending(self):
         """Cancel all pending turn-off tasks."""
         for plug_id in list(self._pending_off.keys()):

+ 14 - 4
backend/tests/integration/test_obico_api.py

@@ -220,14 +220,24 @@ class TestObicoPrinterStatus:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_monitored_subset_is_returned(self, async_client: AsyncClient):
+    async def test_monitored_subset_is_returned(self, async_client: AsyncClient, db_session):
         """A configured obico_enabled_printers subset surfaces (as a sorted list) so
         the frontend can show the idle badge only on monitored printers."""
-        update = await async_client.put("/api/v1/settings/", json={"obico_enabled_printers": "[3, 1]"})
-        assert update.status_code == 200
+        from backend.app.models.printer import Printer
+
+        # The printers must exist: the setting refuses ids that have none.
+        printers = [
+            Printer(name=f"P{i}", serial_number=f"OBICO-SUBSET-{i}", ip_address=f"192.0.2.4{i}", access_code="x")
+            for i in range(2)
+        ]
+        db_session.add_all(printers)
+        await db_session.commit()
+        first, second = sorted(p.id for p in printers)
+        update = await async_client.put("/api/v1/settings/", json={"obico_enabled_printers": f"[{second}, {first}]"})
+        assert update.status_code == 200, update.text
         try:
             response = await async_client.get("/api/v1/obico/printer-status")
-            assert response.json()["monitored_printers"] == [1, 3]
+            assert response.json()["monitored_printers"] == [first, second]
         finally:
             await async_client.put("/api/v1/settings/", json={"obico_enabled_printers": ""})
 

+ 468 - 0
backend/tests/integration/test_printer_delete_scopes.py

@@ -0,0 +1,468 @@
+"""Deleting a printer must not hand what was limited to it to other printers.
+
+A notification provider limited to a printer became one for all printers when
+the printer was deleted (printer_id set to NULL, which means all): every other
+printer's events and camera pictures went to it. An API key limited to the
+printer kept its id, which SQLite gives to the next printer created.
+"""
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select, text
+
+from backend.app.core.database import _drop_deleted_printers_from_limits, _repair_sqlite_orphans, load_all_models
+from backend.app.models.api_key import APIKey
+from backend.app.models.notification import NotificationProvider
+from backend.app.models.printer import Printer
+from backend.app.services.notification_service import notification_service
+
+pytestmark = [pytest.mark.integration]
+
+
+async def _printers(db) -> tuple[int, int]:
+    a = Printer(name="A", serial_number="SCOPE-A", ip_address="192.0.2.21", access_code="x")
+    b = Printer(name="B", serial_number="SCOPE-B", ip_address="192.0.2.22", access_code="x")
+    db.add_all([a, b])
+    await db.commit()
+    return a.id, b.id
+
+
+async def _providers(db, a: int, b: int) -> None:
+    db.add_all(
+        [
+            NotificationProvider(
+                name=name,
+                provider_type="webhook",
+                config="{}",
+                printer_id=printer_id,
+                enabled=True,
+                on_print_start=True,
+            )
+            for name, printer_id in (("only A", a), ("only B", b), ("all printers", None))
+        ]
+    )
+    await db.commit()
+
+
+async def _keys(db, a: int, b: int) -> None:
+    db.add_all(
+        [
+            APIKey(name=name, key_hash="h", key_prefix=f"bb_{name[:6]}", printer_ids=printer_ids)
+            for name, printer_ids in (("A and B", [a, b]), ("only B", [b]), ("all printers", None))
+        ]
+    )
+    await db.commit()
+
+
+async def _scopes(db) -> tuple[dict, dict]:
+    providers = dict((await db.execute(select(NotificationProvider.name, NotificationProvider.printer_id))).all())
+    statement = select(APIKey.name, APIKey.printer_ids).execution_options(populate_existing=True)
+    keys = dict((await db.execute(statement)).all())
+    return providers, keys
+
+
+async def test_deleting_a_printer_removes_what_was_limited_to_it(async_client: AsyncClient, db_session):
+    a, b = await _printers(db_session)
+    await _providers(db_session, a, b)
+    await _keys(db_session, a, b)
+
+    response = await async_client.delete(f"/api/v1/printers/{b}")
+    assert response.status_code == 200, response.text
+
+    providers, keys = await _scopes(db_session)
+    assert providers == {"only A": a, "all printers": None}
+    # A key limited to no printer left may use none ([] means none, None all).
+    assert keys == {"A and B": [a], "only B": [], "all printers": None}
+    recipients = await notification_service._get_providers_for_event(db_session, "on_print_start", a)
+    assert sorted(p.name for p in recipients) == ["all printers", "only A"]
+
+
+async def test_the_startup_repair_takes_deleted_printers_out_of_api_keys(db_session):
+    """SQLite installs that deleted a printer before this fix: keys kept its id.
+
+    (Providers weren't left pointing at it; the delete made them all-printer
+    ones, which nothing can tell from the intended ones.)
+    """
+    a, b = await _printers(db_session)
+    await _keys(db_session, a, b)
+    await db_session.execute(text("DELETE FROM printers WHERE id = :id"), {"id": b})  # the old way
+    await db_session.commit()
+
+    await _repair_sqlite_orphans(await db_session.connection())
+    await _drop_deleted_printers_from_limits(await db_session.connection())
+    await db_session.commit()
+
+    assert (await _scopes(db_session))[1] == {"A and B": [a], "only B": [], "all printers": None}
+
+
+def test_a_provider_goes_with_its_printer_on_postgresql_too():
+    """SET NULL would make it a provider for all printers."""
+    (fk,) = load_all_models().tables["notification_providers"].c.printer_id.foreign_keys
+    assert fk.ondelete == "CASCADE"
+
+
+async def test_a_notify_provider_of_the_printer_is_retired_on_the_service_too(async_client: AsyncClient, db_session):
+    """Deleted as the provider delete route does it: its phone widgets and live
+    activities are cleaned up remotely, not left behind by a cascade."""
+    from unittest.mock import AsyncMock, MagicMock, patch
+
+    _, b = await _printers(db_session)
+    provider = NotificationProvider(name="phones", provider_type="notify", config="{}", printer_id=b)
+    db_session.add(provider)
+    await db_session.commit()
+    provider_id = provider.id
+
+    routes = "backend.app.api.routes.notifications"
+    with (
+        patch(f"{routes}.notify_live_activities.schedule_cleanup", AsyncMock()) as activities,
+        patch(f"{routes}.notify_widgets.schedule_cleanup", AsyncMock()) as widgets,
+        patch(f"{routes}._notify_providers_changed", MagicMock()) as changed,
+    ):
+        response = await async_client.delete(f"/api/v1/printers/{b}")
+
+    assert response.status_code == 200, response.text
+    assert activities.await_args.args[0] == provider_id
+    assert widgets.await_args.args[0] == provider_id
+    changed.assert_called_once()
+    assert (await _scopes(db_session))[0] == {}
+
+
+async def test_a_key_left_with_no_printer_stays_so_after_a_restart(db_session):
+    """[] reaches no printer; a start-up step that turned it into NULL (all) undid that."""
+    from backend.app.core.database import run_migrations
+
+    db_session.add(APIKey(name="none", key_hash="h", key_prefix="bb_none", printer_ids=[]))
+    await db_session.commit()
+
+    await run_migrations(await db_session.connection())
+    await db_session.commit()
+
+    assert (await _scopes(db_session))[1] == {"none": []}
+
+
+async def test_open_connections_lose_the_deleted_printer(async_client: AsyncClient, db_session):
+    """Their printer access is worked out at connect; SQLite gives the id to the next printer."""
+    from unittest.mock import AsyncMock, patch
+
+    _, b = await _printers(db_session)
+    with patch("backend.app.api.routes.printers.ws_manager.refresh_printer_scopes", AsyncMock()) as refresh:
+        response = await async_client.delete(f"/api/v1/printers/{b}")
+
+    assert response.status_code == 200, response.text
+    refresh.assert_awaited_once()
+
+
+async def test_a_virtual_printer_aimed_at_the_printer_goes_with_it(async_client: AsyncClient, db_session):
+    """Without a target a queue-mode one takes jobs for any printer of its model."""
+    from unittest.mock import AsyncMock, patch
+
+    from backend.app.models.virtual_printer import VirtualPrinter
+
+    a, b = await _printers(db_session)
+    db_session.add_all(
+        [
+            VirtualPrinter(name="to B", mode="queue", target_printer_id=b, serial_suffix="391800011"),
+            VirtualPrinter(name="to A", mode="queue", target_printer_id=a, serial_suffix="391800012"),
+        ]
+    )
+    await db_session.commit()
+
+    manager = "backend.app.services.virtual_printer.virtual_printer_manager"
+    with (
+        patch(f"{manager}.remove_instance", AsyncMock()) as removed,
+        patch(f"{manager}.sync_from_db", AsyncMock()),
+    ):
+        response = await async_client.delete(f"/api/v1/printers/{b}")
+
+    assert response.status_code == 200, response.text
+    left = (await db_session.execute(select(VirtualPrinter.name).execution_options(populate_existing=True))).all()
+    assert [name for (name,) in left] == ["to A"]
+    removed.assert_awaited_once()
+
+
+async def test_obico_detection_drops_the_deleted_printer(async_client: AsyncClient, db_session):
+    """SQLite would otherwise enrol the next printer given its id."""
+    from backend.app.models.settings import Settings
+
+    a, b = await _printers(db_session)
+    c = Printer(name="C", serial_number="SCOPE-C", ip_address="192.0.2.23", access_code="x")
+    db_session.add(c)
+    await db_session.commit()
+    db_session.add(Settings(key="obico_enabled_printers", value=f"[{a},{b},{c.id}]"))
+    await db_session.commit()
+
+    response = await async_client.delete(f"/api/v1/printers/{b}")
+
+    assert response.status_code == 200, response.text
+    statement = select(Settings.value).where(Settings.key == "obico_enabled_printers")
+    # Written as the settings page writes it, or it reads as an unsaved change and saves again.
+    assert (await db_session.execute(statement.execution_options(populate_existing=True))).scalar() == f"[{a},{c.id}]"
+
+
+async def test_the_startup_repair_drops_deleted_printers_from_obico_and_virtual_printers(db_session):
+    from backend.app.models.settings import Settings
+    from backend.app.models.virtual_printer import VirtualPrinter
+
+    a, b = await _printers(db_session)
+    db_session.add_all(
+        [
+            Settings(key="obico_enabled_printers", value=f"[{b}]"),
+            VirtualPrinter(name="to B", mode="queue", target_printer_id=b, serial_suffix="391800013"),
+            VirtualPrinter(name="any", mode="queue", target_printer_id=None, serial_suffix="391800014"),
+        ]
+    )
+    await db_session.commit()
+    await db_session.execute(text("DELETE FROM printers WHERE id = :id"), {"id": b})  # the old way
+    await db_session.commit()
+
+    await _repair_sqlite_orphans(await db_session.connection())
+    await _drop_deleted_printers_from_limits(await db_session.connection())
+    await db_session.commit()
+
+    obico = (await db_session.execute(select(Settings.value).where(Settings.key == "obico_enabled_printers"))).scalar()
+    assert obico == "[]"  # none, not all ("" would be all)
+    left = (await db_session.execute(select(VirtualPrinter.name))).all()
+    assert [name for (name,) in left] == ["any"]
+
+
+async def test_its_smart_plugs_go_with_it(async_client: AsyncClient, db_session):
+    """Without a printer a plug is everyone's: users and keys limited to other
+    printers could switch the hardware it still powers."""
+    from datetime import datetime
+    from unittest.mock import patch
+
+    from backend.app.models.smart_plug import SmartPlug
+    from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
+
+    a, b = await _printers(db_session)
+    plugs = [
+        SmartPlug(name="B's", plug_type="mqtt", mqtt_topic="plugs/b", printer_id=b),
+        SmartPlug(name="A's", plug_type="tasmota", ip_address="192.0.2.31", printer_id=a),
+        SmartPlug(name="spare", plug_type="tasmota", ip_address="192.0.2.32", printer_id=None),
+    ]
+    db_session.add_all(plugs)
+    await db_session.commit()
+    b_plug = plugs[0].id
+    db_session.add(SmartPlugEnergySnapshot(plug_id=b_plug, recorded_at=datetime(2026, 10, 1), lifetime_kwh=1.0))
+    await db_session.commit()
+
+    with patch("backend.app.api.routes.smart_plugs.mqtt_relay.smart_plug_service.unsubscribe") as unsubscribe:
+        response = await async_client.delete(f"/api/v1/printers/{b}")
+
+    assert response.status_code == 200, response.text
+    left = (await db_session.execute(select(SmartPlug.name).execution_options(populate_existing=True))).all()
+    assert sorted(name for (name,) in left) == ["A's", "spare"]
+    assert (await db_session.execute(select(SmartPlugEnergySnapshot.id))).all() == []
+    unsubscribe.assert_called_once_with(b_plug)
+
+
+def test_a_smart_plug_goes_with_its_printer_on_postgresql_too():
+    (fk,) = load_all_models().tables["smart_plugs"].c.printer_id.foreign_keys
+    assert fk.ondelete == "CASCADE"
+
+
+@pytest.mark.parametrize("method", ["create", "update"])
+async def test_an_api_key_cant_be_given_a_printer_that_doesnt_exist(async_client: AsyncClient, db_session, method):
+    """SQLite would give that id to the next printer created, and the key with it."""
+    a, _ = await _printers(db_session)
+    if method == "create":
+        response = await async_client.post("/api/v1/api-keys/", json={"name": "k", "printer_ids": [a, 999]})
+    else:
+        created = await async_client.post("/api/v1/api-keys/", json={"name": "k", "printer_ids": [a]})
+        response = await async_client.patch(f"/api/v1/api-keys/{created.json()['id']}", json={"printer_ids": [999]})
+
+    assert response.status_code == 400, response.text
+    assert [ids for _, ids in (await _scopes(db_session))[1].items()] in ([], [[a]])
+
+
+@pytest.mark.parametrize("method", ["create", "update"])
+async def test_a_provider_cant_be_limited_to_a_printer_that_doesnt_exist(async_client: AsyncClient, db_session, method):
+    body = {"name": "p", "provider_type": "webhook", "config": {"webhook_url": "http://192.0.2.99/hook"}}
+    if method == "create":
+        response = await async_client.post("/api/v1/notifications/", json={**body, "printer_id": 999})
+    else:
+        created = await async_client.post("/api/v1/notifications/", json=body)
+        response = await async_client.patch(f"/api/v1/notifications/{created.json()['id']}", json={"printer_id": 999})
+
+    assert response.status_code == 400, response.text
+    assert 999 not in (await _scopes(db_session))[0].values()
+
+
+async def test_obico_cant_be_given_a_printer_that_doesnt_exist(async_client: AsyncClient, db_session):
+    """A settings page still open from before a delete would save the deleted id back."""
+    a, _ = await _printers(db_session)
+
+    response = await async_client.put("/api/v1/settings/", json={"obico_enabled_printers": f"[{a}, 999]"})
+
+    assert response.status_code == 400, response.text
+
+
+async def test_a_slicer_pipeline_aimed_at_the_printer_loses_it(async_client: AsyncClient, db_session):
+    """SQLite would otherwise run it on the next printer given the id."""
+    from backend.app.models.pipeline_run import PipelineJob, PipelineRun
+    from backend.app.models.slicer_pipeline import SlicerPipeline
+
+    _, b = await _printers(db_session)
+    pipeline = SlicerPipeline(
+        name="p",
+        printer_preset_source="local",
+        printer_preset_id="1",
+        process_preset_source="local",
+        process_preset_id="2",
+        filament_presets_json="[]",
+        target_kind="specific_printer",
+        target_printer_id=b,
+    )
+    db_session.add(pipeline)
+    await db_session.commit()
+    run = PipelineRun(pipeline_id=pipeline.id, copies=1)
+    db_session.add(run)
+    await db_session.commit()
+    db_session.add(PipelineJob(pipeline_run_id=run.id, copy_index=0, assigned_printer_id=b))
+    await db_session.commit()
+
+    response = await async_client.delete(f"/api/v1/printers/{b}")
+
+    assert response.status_code == 200, response.text
+    targets = select(SlicerPipeline.target_printer_id).execution_options(populate_existing=True)
+    assert (await db_session.execute(targets)).scalar() is None
+    assigned = select(PipelineJob.assigned_printer_id).execution_options(populate_existing=True)
+    assert (await db_session.execute(assigned)).scalar() is None
+
+
+async def test_obico_ids_already_stored_dont_block_a_save(async_client: AsyncClient, db_session):
+    """PostgreSQL kept the id of a printer deleted before deletes dropped it, and
+    the settings page sends the whole list back: every save would be refused."""
+    from backend.app.models.settings import Settings
+
+    a, _ = await _printers(db_session)
+    db_session.add(Settings(key="obico_enabled_printers", value=f"[{a},999]"))
+    await db_session.commit()
+
+    response = await async_client.put("/api/v1/settings/", json={"obico_enabled_printers": f"[{a},999]"})
+
+    assert response.status_code == 200, response.text
+    response = await async_client.put("/api/v1/settings/", json={"obico_enabled_printers": f"[{a},998]"})
+    assert response.status_code == 400, response.text
+
+
+async def test_the_startup_trim_of_printer_lists_runs_on_postgresql_too(db_session):
+    """PostgreSQL holds stale ids too: kept by its own earlier deletes, or copied
+    from a SQLite backup, after which its sequence hands out the deleted id again."""
+    from unittest.mock import patch
+
+    from backend.app.models.settings import Settings
+    from backend.app.models.virtual_printer import VirtualPrinter
+
+    a, b = await _printers(db_session)
+    await _keys(db_session, a, b)
+    db_session.add_all(
+        [
+            Settings(key="obico_enabled_printers", value=f"[{a},{b}]"),
+            VirtualPrinter(name="to B", mode="queue", target_printer_id=b, serial_suffix="391800016"),
+            VirtualPrinter(name="to A", mode="queue", target_printer_id=a, serial_suffix="391800017"),
+        ]
+    )
+    await db_session.commit()
+    await db_session.execute(text("DELETE FROM printers WHERE id = :id"), {"id": b})  # the old way
+    await db_session.commit()
+
+    with patch("backend.app.core.database.is_sqlite", return_value=False):
+        await _drop_deleted_printers_from_limits(await db_session.connection())
+    await db_session.commit()
+
+    assert (await _scopes(db_session))[1] == {"A and B": [a], "only B": [], "all printers": None}
+    obico = (await db_session.execute(select(Settings.value).where(Settings.key == "obico_enabled_printers"))).scalar()
+    assert obico == f"[{a}]"
+    left = (await db_session.execute(select(VirtualPrinter.name))).all()
+    assert [name for (name,) in left] == ["to A"]
+
+
+async def test_a_deleted_virtual_printer_isnt_rebuilt_from_the_old_settings(db_session):
+    """The move from the single virtual printer settings ran at every start with
+    no virtual printers, rebuilding one aimed at the old target id."""
+    from backend.app.core.database import run_migrations
+    from backend.app.models.settings import Settings
+    from backend.app.models.virtual_printer import VirtualPrinter
+
+    _, b = await _printers(db_session)
+    db_session.add_all(
+        [
+            Settings(key="virtual_printer_enabled", value="true"),
+            Settings(key="virtual_printer_target_printer_id", value=str(b)),
+            VirtualPrinter(name="to B", mode="proxy", target_printer_id=b, serial_suffix="391800015"),
+        ]
+    )
+    await db_session.commit()
+    await run_migrations(await db_session.connection())
+    await db_session.commit()
+
+    await db_session.execute(text("DELETE FROM virtual_printers"))
+    await db_session.commit()
+    await run_migrations(await db_session.connection())
+    await db_session.commit()
+
+    assert (await db_session.execute(select(VirtualPrinter.id))).all() == []
+
+
+async def test_a_pipeline_cant_be_aimed_at_a_printer_that_doesnt_exist(async_client: AsyncClient, db_session):
+    from backend.app.models.slicer_pipeline import SlicerPipeline
+
+    a, _ = await _printers(db_session)
+    pipeline = SlicerPipeline(
+        name="p",
+        printer_preset_source="local",
+        printer_preset_id="1",
+        process_preset_source="local",
+        process_preset_id="2",
+        filament_presets_json='[{"source": "local", "id": "3"}]',
+    )
+    db_session.add(pipeline)
+    await db_session.commit()
+
+    response = await async_client.put(f"/api/v1/slicer-pipelines/{pipeline.id}", json={"target_printer_id": 999})
+    assert response.status_code == 400, response.text
+    response = await async_client.put(f"/api/v1/slicer-pipelines/{pipeline.id}", json={"target_printer_id": a})
+    assert response.status_code == 200, response.text
+
+
+async def test_a_settings_restore_drops_printers_that_are_gone_from_obico(db_session):
+    """A backup taken before a printer delete still lists it."""
+    from backend.app.models.settings import Settings
+    from backend.app.services.github_restore import _CategoryTally, github_restore_service
+
+    a, _ = await _printers(db_session)
+    await github_restore_service._restore_settings(
+        db_session, {"settings": {"obico_enabled_printers": f"[{a},999]"}}, overwrite=True, tally=_CategoryTally()
+    )
+    await db_session.commit()
+
+    obico = (await db_session.execute(select(Settings.value).where(Settings.key == "obico_enabled_printers"))).scalar()
+    assert obico == f"[{a}]"
+
+
+@pytest.mark.parametrize("via", ["printer", "plug"])
+async def test_a_deleted_plugs_pending_auto_off_is_cancelled(async_client: AsyncClient, db_session, via):
+    """It holds the plug's address and the printer id: it would switch the outlet
+    later and, on SQLite, read and mark offline the next printer given the id."""
+    import asyncio
+
+    from backend.app.models.smart_plug import SmartPlug
+    from backend.app.services.smart_plug_manager import smart_plug_manager
+
+    _, b = await _printers(db_session)
+    plug = SmartPlug(name="B's", plug_type="tasmota", ip_address="192.0.2.33", printer_id=b)
+    db_session.add(plug)
+    await db_session.commit()
+    pending = asyncio.create_task(asyncio.sleep(3600))
+    smart_plug_manager._pending_off[plug.id] = pending
+    try:
+        url = f"/api/v1/printers/{b}" if via == "printer" else f"/api/v1/smart-plugs/{plug.id}"
+        response = await async_client.delete(url)
+        assert response.status_code == 200, response.text
+        await asyncio.sleep(0)
+        assert pending.cancelled()
+        assert plug.id not in smart_plug_manager._pending_off
+    finally:
+        pending.cancel()
+        smart_plug_manager._pending_off.pop(plug.id, None)

+ 0 - 45
backend/tests/unit/test_db_dialect.py

@@ -498,51 +498,6 @@ class TestSafeExecutePattern:
             mock_conn, "ALTER TABLE oidc_providers RENAME COLUMN auto_link_existing_accounts TO auto_link"
         )
 
-    @pytest.mark.asyncio
-    async def test_normalize_printer_ids_sqlite_uses_plain_comparison(self):
-        """SQLite path executes plain string comparison (no cast)."""
-        from sqlalchemy import text
-        from sqlalchemy.ext.asyncio import create_async_engine
-
-        from backend.app.core.database import _migrate_normalize_printer_ids
-
-        engine = create_async_engine("sqlite+aiosqlite:///:memory:")
-        async with engine.begin() as conn:
-            await conn.execute(text("CREATE TABLE api_keys (id INTEGER PRIMARY KEY, printer_ids TEXT)"))
-            await conn.execute(text("INSERT INTO api_keys VALUES (1, '[]')"))
-            await conn.execute(text("INSERT INTO api_keys VALUES (2, '[1,2]')"))
-
-            with patch("backend.app.core.database.is_sqlite", return_value=True):
-                await _migrate_normalize_printer_ids(conn)
-
-            result = await conn.execute(text("SELECT id, printer_ids FROM api_keys ORDER BY id"))
-            rows = {r[0]: r[1] for r in result.fetchall()}
-        await engine.dispose()
-
-        assert rows[1] is None, "printer_ids='[]' must be normalised to NULL"
-        assert rows[2] == "[1,2]", "non-empty printer_ids must be unchanged"
-
-    @pytest.mark.asyncio
-    async def test_normalize_printer_ids_postgres_uses_text_cast(self):
-        """PostgreSQL path casts printer_ids to text for comparison (works for json and jsonb)."""
-        from unittest.mock import AsyncMock, MagicMock
-
-        from backend.app.core.database import _migrate_normalize_printer_ids
-
-        nested_cm = MagicMock()
-        nested_cm.__aenter__ = AsyncMock(return_value=nested_cm)
-        nested_cm.__aexit__ = AsyncMock(return_value=False)
-        mock_conn = MagicMock()
-        mock_conn.begin_nested.return_value = nested_cm
-        mock_conn.execute = AsyncMock()
-
-        with patch("backend.app.core.database.is_sqlite", return_value=False):
-            await _migrate_normalize_printer_ids(mock_conn)
-
-        sql = mock_conn.execute.call_args[0][0].text
-        assert "::text = '[]'" in sql, f"Expected ::text cast in SQL, got: {sql}"
-        assert "printer_ids" in sql
-
 
 class TestSpoolmanTableDialect:
     """Phase 1: active_print_spoolman and spool_usage_history use dialect-correct DDL.

+ 58 - 49
backend/tests/unit/test_vp_delete_cleanup.py

@@ -17,57 +17,36 @@ from backend.app.api.routes.virtual_printers import delete_virtual_printer
 
 
 @pytest.mark.asyncio
-async def test_delete_vp_marks_orphan_pending_uploads_discarded(tmp_path):
+async def test_delete_vp_marks_orphan_pending_uploads_discarded(tmp_path, db_session):
     """A VP with PendingUpload rows pointing at its upload_dir: after
     DELETE, those rows must be flipped to ``discarded`` and the on-disk
     directory must be gone."""
-    vp_id = 77
+    from sqlalchemy import select
+
+    from backend.app.models.pending_upload import PendingUpload
+    from backend.app.models.virtual_printer import VirtualPrinter
+
+    vp = VirtualPrinter(name="DeleteMe", serial_suffix="391800077")
+    db_session.add(vp)
+    await db_session.commit()
+    vp_id = vp.id
     upload_dir = tmp_path / "uploads" / str(vp_id)
     upload_dir.mkdir(parents=True)
     (upload_dir / "stale.3mf").write_bytes(b"orphaned content")
 
-    # Build PendingUpload-like mocks. The route mutates `.status`.
-    pending_a = MagicMock()
-    pending_a.file_path = str(upload_dir / "stale.3mf")
-    pending_a.status = "pending"
-    pending_b = MagicMock()
-    pending_b.file_path = str(upload_dir / "another.3mf")
-    pending_b.status = "pending"
-
-    # Unrelated PendingUpload that does NOT belong to this VP — must
-    # be left alone.
-    other_pending = MagicMock()
-    other_pending.file_path = str(tmp_path / "uploads" / "99" / "not-mine.3mf")
-    other_pending.status = "pending"
-
-    # Mock VP row.
-    vp_row = MagicMock()
-    vp_row.id = vp_id
-    vp_row.name = "DeleteMe"
-
-    # Mock DB session with the route's two .execute() calls + flush + commit.
-    select_calls = {"i": 0}
+    def pending(name, path):
+        return PendingUpload(filename=name, file_path=str(path), file_size=1, status="pending")
 
-    async def fake_execute(query):  # noqa: ARG001
-        """Return the VP row on the first call (vp lookup) and the
-        in-range PendingUpload rows on the second call (orphan query).
-        Third call is the DELETE which doesn't need a result."""
-        select_calls["i"] += 1
-        result = MagicMock()
-        if select_calls["i"] == 1:
-            result.scalar_one_or_none = MagicMock(return_value=vp_row)
-        elif select_calls["i"] == 2:
-            scalars = MagicMock()
-            scalars.all = MagicMock(return_value=[pending_a, pending_b])
-            result.scalars = MagicMock(return_value=scalars)
-        return result
+    db_session.add_all(
+        [
+            pending("a", upload_dir / "stale.3mf"),
+            pending("b", upload_dir / "another.3mf"),
+            # Unrelated PendingUpload that does NOT belong to this VP — must be left alone.
+            pending("other", tmp_path / "uploads" / "99" / "not-mine.3mf"),
+        ]
+    )
+    await db_session.commit()
 
-    db = AsyncMock()
-    db.execute = fake_execute
-    db.flush = AsyncMock()
-    db.commit = AsyncMock()
-
-    # Mock the manager: remove_instance, _base_dir, sync_from_db.
     fake_manager = MagicMock()
     fake_manager.remove_instance = AsyncMock()
     fake_manager.sync_from_db = AsyncMock()
@@ -77,13 +56,17 @@ async def test_delete_vp_marks_orphan_pending_uploads_discarded(tmp_path):
         "backend.app.services.virtual_printer.virtual_printer_manager",
         fake_manager,
     ):
-        await delete_virtual_printer(vp_id=vp_id, db=db, _=None)
-
-    # Both in-range PendingUpload rows must be flipped to "discarded".
-    assert pending_a.status == "discarded"
-    assert pending_b.status == "discarded"
-    # The unrelated row was never returned from the query — left alone.
-    assert other_pending.status == "pending"
+        await delete_virtual_printer(vp_id=vp_id, db=db_session, _=None)
+
+    statuses = dict(
+        (
+            await db_session.execute(
+                select(PendingUpload.filename, PendingUpload.status).execution_options(populate_existing=True)
+            )
+        ).all()
+    )
+    assert statuses == {"a": "discarded", "b": "discarded", "other": "pending"}
+    assert await db_session.get(VirtualPrinter, vp_id) is None
 
     # The on-disk upload_dir is gone.
     assert not upload_dir.exists()
@@ -135,3 +118,29 @@ async def test_delete_vp_with_no_orphan_uploads_still_succeeds(tmp_path):
     fake_manager.remove_instance.assert_awaited_once_with(vp_id)
     # No directory to remove — and we didn't crash trying to.
     assert not (tmp_path / "uploads" / str(vp_id)).exists()
+
+
+@pytest.mark.asyncio
+async def test_deleting_vp_1_leaves_the_uploads_of_vp_12_alone(tmp_path, db_session):
+    """uploads/1 is a prefix of uploads/12/... as a string, not as a folder."""
+    from sqlalchemy import select
+
+    from backend.app.api.routes.virtual_printers import drop_virtual_printer
+    from backend.app.models.pending_upload import PendingUpload
+
+    fake_manager = MagicMock()
+    fake_manager._base_dir = tmp_path
+    db_session.add_all(
+        [
+            PendingUpload(filename="mine", file_path=str(tmp_path / "uploads" / "1" / "a.3mf"), file_size=1),
+            PendingUpload(filename="vp 12", file_path=str(tmp_path / "uploads" / "12" / "b.3mf"), file_size=1),
+        ]
+    )
+    await db_session.commit()
+
+    with patch("backend.app.services.virtual_printer.virtual_printer_manager", fake_manager):
+        await drop_virtual_printer(db_session, 1)
+    await db_session.commit()
+
+    statement = select(PendingUpload.filename, PendingUpload.status).execution_options(populate_existing=True)
+    assert dict((await db_session.execute(statement)).all()) == {"mine": "discarded", "vp 12": "pending"}

+ 93 - 1
frontend/src/__tests__/components/FailureDetectionSettings.test.tsx

@@ -3,7 +3,7 @@
  */
 
 import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { screen, waitFor } from '@testing-library/react';
+import { screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { FailureDetectionSettings } from '../../components/FailureDetectionSettings';
@@ -229,6 +229,98 @@ describe('FailureDetectionSettings', () => {
     });
   });
 
+  describe('printers that are gone', () => {
+    const limited = { ...baseSettings, obico_enabled: true, obico_enabled_printers: '[1,2]' };
+    const printerOne = [{ id: 1, name: 'A', serial_number: 'S1', model: 'X1C', is_active: true }];
+
+    it('sends back printers this user cannot see', async () => {
+      // GET /printers lists only the caller's printers; one missing from it may
+      // just be hidden. The server drops deleted ones itself.
+      let saved: Record<string, unknown> | null = null;
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json(limited)),
+        http.get('/api/v1/printers', () => HttpResponse.json(printerOne)),
+        http.put('/api/v1/settings/', async ({ request }) => {
+          saved = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ ...limited, ...saved });
+        }),
+      );
+      render(<FailureDetectionSettings />);
+      const input = await screen.findByPlaceholderText(/ML_API_TOKEN/i);
+      await waitFor(() => expect(input).not.toBeDisabled());
+      await screen.findByText('A');
+      await userEvent.type(input, 'typed');
+
+      await waitFor(() => expect(saved).not.toBeNull(), { timeout: 3000 });
+      expect(saved!.obico_enabled_printers).toBe('[1,2]');
+    });
+
+    it('takes back what the server refused, so the next save goes through', async () => {
+      // Printer 5 was deleted elsewhere after this page listed it.
+      const stored = { ...limited, obico_enabled_printers: '[1]' };
+      let printerCalls = 0;
+      let saved: Record<string, unknown> | null = null;
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json(stored)),
+        http.get('/api/v1/printers', () =>
+          HttpResponse.json(
+            printerCalls++ === 0
+              ? [...printerOne, { id: 5, name: 'E', serial_number: 'S5', model: 'X1C', is_active: true }]
+              : printerOne,
+          ),
+        ),
+        http.put('/api/v1/settings/', async ({ request }) => {
+          const body = (await request.json()) as Record<string, unknown>;
+          if (body.obico_enabled_printers === '[1,5]') {
+            return HttpResponse.json({ detail: 'Unknown printer: 5' }, { status: 400 });
+          }
+          saved = body;
+          return HttpResponse.json({ ...stored, ...body });
+        }),
+      );
+      render(<FailureDetectionSettings />);
+      const input = await screen.findByPlaceholderText(/ML_API_TOKEN/i);
+      await waitFor(() => expect(input).not.toBeDisabled());
+      await userEvent.click(within((await screen.findByText('E')).closest('label')!).getByRole('checkbox'));
+      expect(await screen.findByText(/Unknown printer: 5/, {}, { timeout: 3000 })).toBeInTheDocument();
+      await waitFor(() => expect(screen.queryByText('E')).not.toBeInTheDocument());
+
+      await userEvent.type(input, 'typed');
+      await waitFor(() => expect(saved).not.toBeNull(), { timeout: 3000 });
+      expect(saved!.obico_enabled_printers).toBe('[1]');
+    });
+
+    it('keeps an address being typed when the server refuses it as unfinished', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json({ ...baseSettings, obico_enabled: true })),
+        http.put('/api/v1/settings/', () =>
+          HttpResponse.json({ detail: [{ msg: 'URL must include a hostname' }] }, { status: 422 }),
+        ),
+      );
+      render(<FailureDetectionSettings />);
+      const input = await screen.findByPlaceholderText('http://192.168.1.10:3333');
+      await waitFor(() => expect(input).not.toBeDisabled());
+      await userEvent.type(input, 'http://');
+
+      expect(await screen.findByText(/Settings not saved/, {}, { timeout: 3000 })).toBeInTheDocument();
+      await new Promise((resolve) => setTimeout(resolve, 300));
+      expect(input).toHaveValue('http://');
+    });
+
+    it('says so when the save is refused', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json(limited)),
+        http.put('/api/v1/settings/', () => HttpResponse.json({ detail: 'Unknown printer: 2' }, { status: 400 })),
+      );
+      render(<FailureDetectionSettings />);
+      const input = await screen.findByPlaceholderText(/ML_API_TOKEN/i);
+      await waitFor(() => expect(input).not.toBeDisabled());
+      await userEvent.type(input, 'typed');
+
+      expect(await screen.findByText(/Unknown printer: 2/, {}, { timeout: 3000 })).toBeInTheDocument();
+    });
+  });
+
   it('shows failure class history entries with red styling', async () => {
     server.use(
       http.get('/api/v1/obico/status', () =>

+ 30 - 9
frontend/src/components/FailureDetectionSettings.tsx

@@ -2,7 +2,8 @@ import { useState, useEffect, useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { Loader2, ScanEye, Check, X, AlertTriangle, Info } from 'lucide-react';
-import { api } from '../api/client';
+import { api, ApiError } from '../api/client';
+import type { AppSettings } from '../api/client';
 import { Card, CardContent, CardHeader } from './Card';
 import { Button } from './Button';
 import { Toggle } from './Toggle';
@@ -25,6 +26,16 @@ function isValidInternalUrl(value: string): boolean {
   }
 }
 
+// The printers Obico watches as stored; null is all of them.
+function storedPrinterList(stored: AppSettings): number[] | null {
+  try {
+    const list = stored.obico_enabled_printers ? (JSON.parse(stored.obico_enabled_printers) as number[]) : null;
+    return Array.isArray(list) ? list : null;
+  } catch {
+    return null;
+  }
+}
+
 export function FailureDetectionSettings() {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
@@ -67,14 +78,7 @@ export function FailureDetectionSettings() {
     setSensitivity(settings.obico_sensitivity ?? 'medium');
     setAction(settings.obico_action ?? 'notify');
     setPollInterval(settings.obico_poll_interval ?? 10);
-    try {
-      const list = settings.obico_enabled_printers
-        ? (JSON.parse(settings.obico_enabled_printers) as number[])
-        : null;
-      setEnabledPrinters(Array.isArray(list) ? list : null);
-    } catch {
-      setEnabledPrinters(null);
-    }
+    setEnabledPrinters(storedPrinterList(settings));
     setInitialized(true);
   }, [settings]);
 
@@ -92,6 +96,23 @@ export function FailureDetectionSettings() {
         obico_poll_interval: pollInterval,
         obico_enabled_printers: enabledPrinters === null ? '' : JSON.stringify(enabledPrinters),
       }),
+    onError: async (error: Error) => {
+      showToast(t('settings.toast.settingsSaveFailed', { message: error.message }), 'error');
+      // A 400 is a printer deleted while this page was open. The printer list
+      // goes back to what the server kept, or every later edit would send the
+      // refused id again; set here, as unchanged settings keep their reference
+      // and the effect would not run. Not filtered against the printers list,
+      // which holds only the caller's printers. Other fields keep what was
+      // typed: a half-typed address is refused with 422 until it is finished.
+      if (!(error instanceof ApiError) || error.status !== 400) return;
+      queryClient.invalidateQueries({ queryKey: ['printers'] });
+      try {
+        const stored = await queryClient.fetchQuery({ queryKey: ['settings'], queryFn: api.getSettings, staleTime: 0 });
+        setEnabledPrinters(storedPrinterList(stored));
+      } catch {
+        // Server not reachable: the list keeps the edit, and the next one tries again.
+      }
+    },
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['settings'] });
       queryClient.invalidateQueries({ queryKey: ['obico-status'] });

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

@@ -663,7 +663,7 @@ export default {
     // Confirm modals
     confirm: {
       deleteTitle: 'Drucker löschen',
-      deleteMessage: 'Möchten Sie "{{name}}" wirklich löschen? Alle Verbindungseinstellungen werden entfernt.',
+      deleteMessage: 'Möchten Sie "{{name}}" wirklich löschen? Alle Verbindungseinstellungen werden entfernt. Ihm zugeordnete Benachrichtigungsanbieter und Smart Plugs sowie virtuelle Drucker, die an ihn senden, werden ebenfalls gelöscht.',
       deleteArchivesNote: 'Der gesamte Druckverlauf für diesen Drucker wird dauerhaft gelöscht.',
       keepArchivesNote: 'Der Druckverlauf wird beibehalten, aber nicht mehr mit diesem Drucker verknüpft.',
       stopTitle: 'Druck stoppen',
@@ -2683,6 +2683,7 @@ export default {
       enterGroupName: 'Bitte geben Sie einen Gruppennamen ein',
       settingsSaved: 'Einstellungen gespeichert',
       noPermissionUpdate: 'Sie haben keine Berechtigung, Einstellungen zu ändern',
+      settingsSaveFailed: 'Einstellungen nicht gespeichert: {{message}}',
       cameraSettingsSaved: 'Kamera-Einstellungen gespeichert',
       enterCameraUrl: 'Bitte geben Sie eine Kamera-URL ein',
       passwordChanged: 'Passwort erfolgreich geändert',

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

@@ -668,7 +668,7 @@ export default {
     // Confirm modals
     confirm: {
       deleteTitle: 'Delete Printer',
-      deleteMessage: 'Are you sure you want to delete "{{name}}"? This will remove all connection settings.',
+      deleteMessage: 'Are you sure you want to delete "{{name}}"? This will remove all connection settings. Notification providers and smart plugs assigned to it, and virtual printers that send to it, are deleted too.',
       deleteArchivesNote: 'All print history for this printer will be permanently deleted.',
       keepArchivesNote: 'Print history will be kept but no longer associated with this printer.',
       stopTitle: 'Stop Print',
@@ -2705,6 +2705,7 @@ export default {
       enterGroupName: 'Please enter a group name',
       settingsSaved: 'Settings saved',
       noPermissionUpdate: 'You do not have permission to change settings',
+      settingsSaveFailed: 'Settings not saved: {{message}}',
       cameraSettingsSaved: 'Camera settings saved',
       enterCameraUrl: 'Please enter a camera URL',
       passwordChanged: 'Password changed successfully',

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

@@ -663,7 +663,7 @@ export default {
     // Confirm modals
     confirm: {
       deleteTitle: 'Eliminar impresora',
-      deleteMessage: '¿Está seguro de que desea eliminar "{{name}}"? Esto eliminará todos los ajustes de conexión.',
+      deleteMessage: '¿Está seguro de que desea eliminar "{{name}}"? Esto eliminará todos los ajustes de conexión. También se eliminan los proveedores de notificaciones y enchufes inteligentes asignados a ella, y las impresoras virtuales que envían a ella.',
       deleteArchivesNote: 'Todo el historial de impresión de esta impresora se eliminará permanentemente.',
       keepArchivesNote: 'El historial de impresión se conservará pero ya no estará asociado a esta impresora.',
       stopTitle: 'Detener impresión',
@@ -2685,6 +2685,7 @@ export default {
       enterGroupName: 'Introduzca un nombre de grupo',
       settingsSaved: 'Ajustes guardados',
       noPermissionUpdate: 'No tiene permiso para cambiar los ajustes',
+      settingsSaveFailed: 'Ajustes no guardados: {{message}}',
       cameraSettingsSaved: 'Ajustes de la cámara guardados',
       enterCameraUrl: 'Introduzca una URL de cámara',
       passwordChanged: 'Contraseña cambiada correctamente',

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

@@ -663,7 +663,7 @@ export default {
     // Confirm modals
     confirm: {
       deleteTitle: 'Supprimer l\'imprimante',
-      deleteMessage: 'Supprimer "{{name}}" ? Cela retirera tous les paramètres de connexion.',
+      deleteMessage: 'Supprimer "{{name}}" ? Cela retirera tous les paramètres de connexion. Les fournisseurs de notifications et prises connectées qui lui sont attribués, ainsi que les imprimantes virtuelles qui lui envoient des travaux, sont aussi supprimés.',
       deleteArchivesNote: 'Tout l\'historique sera définitivement supprimé.',
       keepArchivesNote: 'L\'historique sera conservé mais plus associé à cette imprimante.',
       stopTitle: 'Arrêter l\'impression',
@@ -2638,6 +2638,7 @@ export default {
       enterGroupName: 'Entrez un nom de groupe',
       settingsSaved: 'Paramètres enregistrés',
       noPermissionUpdate: "Vous n'avez pas l'autorisation de modifier les paramètres",
+      settingsSaveFailed: 'Paramètres non enregistrés : {{message}}',
       cameraSettingsSaved: 'Réglages caméra enregistrés',
       enterCameraUrl: 'Entrez une URL caméra',
       passwordChanged: 'Mot de passe modifié',

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

@@ -663,7 +663,7 @@ export default {
     // Confirm modals
     confirm: {
       deleteTitle: 'Elimina Stampante',
-      deleteMessage: 'Sei sicuro di eliminare "{{name}}"? Questo rimuoverà tutte le impostazioni di connessione.',
+      deleteMessage: 'Sei sicuro di eliminare "{{name}}"? Questo rimuoverà tutte le impostazioni di connessione. Vengono eliminati anche i provider notifiche e le prese smart assegnati a essa, e le stampanti virtuali che inviano a essa.',
       deleteArchivesNote: 'Tutta la cronologia di stampa sarà eliminata definitivamente.',
       keepArchivesNote: 'La cronologia sarà mantenuta ma non più associata a questa stampante.',
       stopTitle: 'Ferma Stampa',
@@ -2638,6 +2638,7 @@ export default {
       enterGroupName: 'Inserisci un nome gruppo',
       settingsSaved: 'Impostazioni salvate',
       noPermissionUpdate: 'Non hai il permesso di modificare le impostazioni',
+      settingsSaveFailed: 'Impostazioni non salvate: {{message}}',
       cameraSettingsSaved: 'Impostazioni camera salvate',
       enterCameraUrl: 'Inserisci un URL camera',
       passwordChanged: 'Password cambiata con successo',

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

@@ -662,7 +662,7 @@ export default {
     // Confirm modals
     confirm: {
       deleteTitle: 'プリンターを削除',
-      deleteMessage: '「{{name}}」を削除しますか?すべての接続設定が削除されます。',
+      deleteMessage: '「{{name}}」を削除しますか?すべての接続設定が削除されます。このプリンターに割り当てられた通知プロバイダーとスマートプラグ、およびこのプリンターに送信する仮想プリンターも削除されます。',
       deleteArchivesNote: 'このプリンターのすべての印刷履歴が完全に削除されます。',
       keepArchivesNote: '印刷履歴は保持されますが、このプリンターとの関連は解除されます。',
       stopTitle: '印刷を停止',
@@ -2682,6 +2682,7 @@ export default {
       enterGroupName: 'グループ名を入力',
       settingsSaved: '設定を保存しました',
       noPermissionUpdate: '設定を変更する権限がありません',
+      settingsSaveFailed: '設定は保存されませんでした: {{message}}',
       cameraSettingsSaved: 'カメラ設定を保存しました',
       enterCameraUrl: 'カメラURLを入力してください',
       passwordChanged: 'パスワードが正常に変更されました',

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

@@ -628,7 +628,7 @@ export default {
     },
     confirm: {
       deleteTitle: '프린터 삭제',
-      deleteMessage: '"{{name}}"을(를) 삭제하시겠습니까? 모든 연결 설정이 제거됩니다.',
+      deleteMessage: '"{{name}}"을(를) 삭제하시겠습니까? 모든 연결 설정이 제거됩니다. 이 프린터에 할당된 알림 제공자와 스마트 플러그, 이 프린터로 전송하는 가상 프린터도 함께 삭제됩니다.',
       deleteArchivesNote: '이 프린터의 모든 인쇄 기록이 영구적으로 삭제됩니다.',
       keepArchivesNote: '인쇄 기록은 유지되지만 이 프린터와 연결이 해제됩니다.',
       stopTitle: '인쇄 정지',
@@ -2545,6 +2545,7 @@ export default {
       enterGroupName: '그룹 이름을 입력하세요',
       settingsSaved: '설정이 저장되었습니다',
       noPermissionUpdate: '설정을 변경할 권한이 없습니다',
+      settingsSaveFailed: '설정이 저장되지 않았습니다: {{message}}',
       cameraSettingsSaved: '카메라 설정이 저장되었습니다',
       enterCameraUrl: '카메라 URL을 입력하세요',
       passwordChanged: '비밀번호가 성공적으로 변경되었습니다',

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

@@ -666,7 +666,7 @@ export default {
     // Confirm modals
     confirm: {
       deleteTitle: 'Printer verwijderen',
-      deleteMessage: 'Weet je zeker dat je "{{name}}" wilt verwijderen? Hiermee worden alle verbindingsinstellingen verwijderd.',
+      deleteMessage: 'Weet je zeker dat je "{{name}}" wilt verwijderen? Hiermee worden alle verbindingsinstellingen verwijderd. Meldingsproviders en slimme stekkers die eraan zijn toegewezen, en virtuele printers die ernaar versturen, worden ook verwijderd.',
       deleteArchivesNote: 'De volledige afdrukgeschiedenis van deze printer wordt permanent verwijderd.',
       keepArchivesNote: 'De afdrukgeschiedenis blijft bewaard, maar is niet langer aan deze printer gekoppeld.',
       stopTitle: 'Afdruk stoppen',
@@ -2703,6 +2703,7 @@ export default {
       enterGroupName: 'Voer een groepsnaam in',
       settingsSaved: 'Instellingen opgeslagen',
       noPermissionUpdate: 'Je hebt geen toestemming om instellingen te wijzigen',
+      settingsSaveFailed: 'Instellingen niet opgeslagen: {{message}}',
       cameraSettingsSaved: 'Camera-instellingen opgeslagen',
       enterCameraUrl: 'Voer een camera-URL in',
       passwordChanged: 'Wachtwoord succesvol gewijzigd',

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

@@ -663,7 +663,7 @@ export default {
     // Confirm modals
     confirm: {
       deleteTitle: 'Excluir Impressora',
-      deleteMessage: 'Tem certeza de que deseja excluir "{{name}}"? Isso removerá todas as configurações de conexão.',
+      deleteMessage: 'Tem certeza de que deseja excluir "{{name}}"? Isso removerá todas as configurações de conexão. Provedores de notificação e tomadas inteligentes atribuídos a ela, e impressoras virtuais que enviam para ela, também são excluídos.',
       deleteArchivesNote: 'Todo o histórico de impressão desta impressora será permanentemente excluído.',
       keepArchivesNote: 'O histórico de impressão será mantido, mas não estará mais associado a esta impressora.',
       stopTitle: 'Parar Impressão',
@@ -2638,6 +2638,7 @@ export default {
       enterGroupName: 'Por favor, insira um nome de grupo',
       settingsSaved: 'Configurações salvas',
       noPermissionUpdate: 'Você não tem permissão para alterar as configurações',
+      settingsSaveFailed: 'Configurações não salvas: {{message}}',
       cameraSettingsSaved: 'Configurações da câmera salvas',
       enterCameraUrl: 'Por favor, insira a URL da câmera',
       passwordChanged: 'Senha alterada com sucesso',

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

@@ -633,7 +633,7 @@ export default {
     },
     confirm: {
       deleteTitle: "Удалить принтер",
-      deleteMessage: "Удалить принтер «{{name}}»? Все параметры подключения будут удалены.",
+      deleteMessage: "Удалить принтер «{{name}}»? Все параметры подключения будут удалены. Провайдеры уведомлений и умные розетки, назначенные ему, а также виртуальные принтеры, которые отправляют на него, тоже будут удалены.",
       deleteArchivesNote: "Вся история печати этого принтера будет удалена безвозвратно.",
       keepArchivesNote: "История печати сохранится, но больше не будет связана с этим принтером.",
       stopTitle: "Остановить печать",
@@ -2553,6 +2553,7 @@ export default {
       enterGroupName: "Введите название группы",
       settingsSaved: "Настройки сохранены",
       noPermissionUpdate: "У вас нет прав на изменение настроек",
+      settingsSaveFailed: 'Настройки не сохранены: {{message}}',
       cameraSettingsSaved: "Настройки камеры сохранены",
       enterCameraUrl: "Введите URL камеры",
       passwordChanged: "Пароль изменён",

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

@@ -666,7 +666,7 @@ export default {
     // Confirm modals
     confirm: {
       deleteTitle: 'Ta bort skrivare',
-      deleteMessage: 'Är du säker på att du vill ta bort "{{name}}"? Detta kommer att ta bort alla anslutningsinställningar.',
+      deleteMessage: 'Är du säker på att du vill ta bort "{{name}}"? Detta kommer att ta bort alla anslutningsinställningar. Notisleverantörer och smarta kontakter som är kopplade till den, och virtuella skrivare som skickar till den, tas också bort.',
       deleteArchivesNote: 'All utskriftshistorik för denna skrivare kommer att tas bort permanent.',
       keepArchivesNote: 'Utskriftshistorik kommer att behållas men inte längre vara kopplad till denna skrivare.',
       stopTitle: 'Stoppa utskrift',
@@ -2702,6 +2702,7 @@ export default {
       enterGroupName: 'Ange ett gruppnamn',
       settingsSaved: 'Inställningar sparade',
       noPermissionUpdate: 'Du har inte behörighet att ändra inställningar',
+      settingsSaveFailed: 'Inställningarna sparades inte: {{message}}',
       cameraSettingsSaved: 'Kamerainställningar sparade',
       enterCameraUrl: 'Ange URL för en kamera',
       passwordChanged: 'Lösenord ändrat',

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

@@ -663,7 +663,7 @@ export default {
     // Onay modalleri
     confirm: {
       deleteTitle: 'Yazıcıyı Sil',
-      deleteMessage: '"{{name}}" silmek istediğinizden emin misiniz? Bu, tüm bağlantı ayarlarını kaldıracak.',
+      deleteMessage: '"{{name}}" silmek istediğinizden emin misiniz? Bu, tüm bağlantı ayarlarını kaldıracak. Ona atanmış bildirim sağlayıcıları ve akıllı prizler ile ona gönderen sanal yazıcılar da silinir.',
       deleteArchivesNote: 'Bu yazıcı için tüm baskı geçmişi kalıcı olarak silinecek.',
       keepArchivesNote: 'Baskı geçmişi saklanacak ancak artık bu yazıcıyla ilişkilendirilmeyecek.',
       stopTitle: 'Baskıyı Durdur',
@@ -2685,6 +2685,7 @@ export default {
       enterGroupName: 'Lütfen bir grup adı girin',
       settingsSaved: 'Ayarlar kaydedildi',
       noPermissionUpdate: 'Ayarları değiştirme izniniz yok',
+      settingsSaveFailed: 'Ayarlar kaydedilmedi: {{message}}',
       cameraSettingsSaved: 'Kamera ayarları kaydedildi',
       enterCameraUrl: 'Lütfen bir kamera URL\'si girin',
       passwordChanged: 'Parola başarıyla değiştirildi',

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

@@ -666,7 +666,7 @@ export default {
     // Confirm modals
     confirm: {
       deleteTitle: "Видалити принтер",
-      deleteMessage: "Ви впевнені, що хочете видалити \"{{name}}\"? Це призведе до видалення всіх налаштувань підключення.",
+      deleteMessage: "Ви впевнені, що хочете видалити \"{{name}}\"? Це призведе до видалення всіх налаштувань підключення. Служби сповіщень і розумні розетки, призначені йому, а також віртуальні принтери, що надсилають на нього, теж буде видалено.",
       deleteArchivesNote: "Усю історію друку для цього принтера буде остаточно видалено.",
       keepArchivesNote: "Історія друку буде збережена, але більше не пов’язана з цим принтером.",
       stopTitle: "Зупинити друк",
@@ -2700,6 +2700,7 @@ export default {
       enterGroupName: "Будь ласка, введіть назву групи",
       settingsSaved: "Налаштування збережено",
       noPermissionUpdate: "Ви не маєте дозволу змінювати налаштування",
+      settingsSaveFailed: 'Налаштування не збережено: {{message}}',
       cameraSettingsSaved: "Налаштування камери збережено",
       enterCameraUrl: "Будь ласка, введіть камеру URL",
       passwordChanged: "Пароль успішно змінено",

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

@@ -663,7 +663,7 @@ export default {
     // Confirm modals
     confirm: {
       deleteTitle: '删除打印机',
-      deleteMessage: '确定要删除"{{name}}"吗?这将移除所有连接设置。',
+      deleteMessage: '确定要删除"{{name}}"吗?这将移除所有连接设置。分配给它的通知提供商和智能插座,以及发送到它的虚拟打印机,也会一并删除。',
       deleteArchivesNote: '此打印机的所有打印历史将被永久删除。',
       keepArchivesNote: '打印历史将保留,但不再与此打印机关联。',
       stopTitle: '停止打印',
@@ -2683,6 +2683,7 @@ export default {
       enterGroupName: '请输入组名称',
       settingsSaved: '设置已保存',
       noPermissionUpdate: '您没有权限更改设置',
+      settingsSaveFailed: '设置未保存:{{message}}',
       cameraSettingsSaved: '摄像头设置已保存',
       enterCameraUrl: '请输入摄像头 URL',
       passwordChanged: '密码修改成功',

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

@@ -663,7 +663,7 @@ export default {
     // Confirm modals
     confirm: {
       deleteTitle: '刪除印表機',
-      deleteMessage: '確定要刪除"{{name}}"嗎?這將移除所有連線設定。',
+      deleteMessage: '確定要刪除"{{name}}"嗎?這將移除所有連線設定。指派給它的通知提供者和智慧插座,以及傳送到它的虛擬印表機,也會一併刪除。',
       deleteArchivesNote: '此印表機的所有列印歷史將被永久刪除。',
       keepArchivesNote: '列印歷史將保留,但不再與此印表機連結。',
       stopTitle: '停止列印',
@@ -2683,6 +2683,7 @@ export default {
       enterGroupName: '請輸入群組名稱',
       settingsSaved: '設定已儲存',
       noPermissionUpdate: '您沒有權限變更設定',
+      settingsSaveFailed: '設定未儲存:{{message}}',
       cameraSettingsSaved: '攝影機設定已儲存',
       enterCameraUrl: '請輸入攝影機 URL',
       passwordChanged: '密碼修改成功',

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

@@ -2675,6 +2675,14 @@ function PrinterCard({
       queryClient.invalidateQueries({ queryKey: ['printers'] });
       queryClient.invalidateQueries({ queryKey: ['archives'] });
       queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
+      // What was limited to the printer went with it (providers, virtual
+      // printers, smart plugs) or lost it (API keys, Obico).
+      queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
+      queryClient.invalidateQueries({ queryKey: ['virtual-printers'] });
+      queryClient.invalidateQueries({ queryKey: ['api-keys'] });
+      queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
+      // Obico's printer list lost it; a cached copy would save it back.
+      queryClient.invalidateQueries({ queryKey: ['settings'] });
     },
     onError: (error: Error) => showToast(error.message || t('printers.toast.failedToDelete'), 'error'),
   });

File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/AdditionalSection-BiWcr8t-.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/AmsUnitCard-BqP8TlS_.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ArchivesPage-DXiVYMDB.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/AssignToAmsModal-fG2qF5AK.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/CamWallPage-wODR7sNv.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/CameraDiagnoseModal-DNg5SJRE.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/CameraPage-fQ3AXYe3.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/CameraWall-DDF6DxOV.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ColumnConfigModal-B_K6EHWV.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ConfigureAmsSlotModal-dO4k_KKq.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ConnectAuthorizePage-Bcjxtwf2.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ContextMenu-BK0yhxxB.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ExternalLinkPage-BbY3mS-F.js


File diff ditekan karena terlalu besar
+ 0 - 1
static/assets/FileManagerPage-DPjM1Bxu.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/FileUploadModal-CnGxZdU5.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/FinancePage-D70J3-En.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/GCodeViewerPage-Cc6svojb.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/GroupEditPage-BupGxnD8.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ImagePreviewModal-vFJXoHIW.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/InventoryPage-CmzhHa0A.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/LibraryTrashPage-pChXBEnV.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/LoginPage-CKIxI_EP.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/MaintenancePage-Cil_uqXN.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ModelSourcesPage-CQeHEPPm.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/NotificationsPage-BRDkDorp.js


File diff ditekan karena terlalu besar
+ 0 - 1
static/assets/PdfPreviewModal-YNm_y-Xf.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/PhotoGalleryModal-CtTNH5iJ.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/PrinterLocationsPage-kNqbZpyy.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/PrintersPage-DUj4FgER.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ProfilesPage-DizkuEU_.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ProjectDetailPage-DHQB8TsM.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ProjectsPage-DyP5GAcR.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/QueuePage-m3Gi8n-g.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/RichTextEditor-B2hc_1Ni.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/SettingsPage-CflGzdcE.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/SetupPage-dFn8IThZ.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/SliceModal-Dma4cWn-.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/SpoolBuddyAmsPage-D2I328Of.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-DTRCWbxa.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/SpoolBuddyDashboard-DlbKB8kO.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-gKr2Wetg.js


File diff ditekan karena terlalu besar
+ 1 - 1
static/assets/SpoolBuddySettingsPage-CT-TF6ZC.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-DX8ozw6r.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/SpreadsheetPreviewModal-CBtN2Lza.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/StatsPage-HJqcVOBc.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/StreamOverlayPage-C7WeayO1.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/SystemInfoPage-Bkkq9QOf.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/de-BBbbJU4T.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/de-d4f0ctP_.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/es-Bz63egNE.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/es-Dj4End9b.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/fr-BUDFE3iT.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/fr-CmF999mK.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/index-3KXgokKF.js


+ 1 - 1
static/assets/inventorySearch-DnBIB0ZZ.js → static/assets/inventorySearch-nSMyBii7.js

@@ -1 +1 @@
-import{ft as e}from"./index-C1KZGB01.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};
+import{ft as e}from"./index-3KXgokKF.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};

File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/it-BwpvLhyg.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/it-CFF2YIPs.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ja-B-VXWExv.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ja-qS_D-r1-.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ko-B-kp19gj.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/ko-Bs5eRzkr.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/locationSensorDefaults-Cko4Ujn6.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/nl-855facJr.js


Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini