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

feat(printers): per-printer Maintenance Mode toggle (#1476)

  Operator-flipped out-of-service state per printer for three scenarios:
  parallel Bambuddy installs (dev + prod where the printer rejects all
  but one MQTT client), printers under repair, and temporary suspension.

  The backend Printer.is_active gate has shipped since day one and is
  already honoured by every consumer — MQTT (printer_manager), queue
  dispatch (print_scheduler, print_queue), metrics, scheduler, picker,
  backup, maintenance dashboard. The missing piece was UI exposure.

  Three entry points to flip is_active:
  - Three-dot overflow menu (Enter / Exit maintenance mode, wrench icon)
  - Exit button inside the in-card amber panel
  - Checkbox in EditPrinterModal

  Card UI: expanded mode shows an amber panel (Wrench + "In Maintenance"
  + subtitle + Exit) where the cover/progress container would normally
  render — same height, no layout shift. Compact mode shows an amber
  pill in place of the progress bar. Header pill swaps Connected/Offline
  for "Maintenance" and the diagnostic CTA is suppressed (deliberate
  state, not involuntary offline). HMS / Queue / Firmware pills fall
  away naturally via the existing status?.connected gates.

  Mid-print entry (RUNNING / PAUSE) triggers a confirmation dialog —
  disconnecting MQTT mid-print stops progress tracking and completion
  notifications for the in-flight job. Idle / FINISH / FAILED skip the
  dialog and toggle directly.

  Scope: no backend change, no new permission, no behaviour change for
  any other consumer. PrinterCreate.is_active?: boolean added to the
  TypeScript surface so the field flows through api.updatePrinter.
maziggy 2 месяцев назад
Родитель
Сommit
36a16b8ae4

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 1 - 0
README.md

@@ -150,6 +150,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 - Printer control (stop, pause, resume, chamber light, print speed, **airduct mode** for P2S/H2*, **temperature setpoints** for nozzle / bed / **chamber heater** on H2C/H2D/H2DPro/H2S/X2D, **Z-jog / XY-jog / extruder jog**, customizable temperature & fan presets under Settings → Workflow)
 - **Status badges on printer card**: SD Card (green / red), Enclosure Door (green / yellow — X1/P1S/P2S/H2*), Airduct Mode (cooling / heating)
 - **Force Refresh** menu item — request a full status push from the printer without reconnecting
+- **Maintenance Mode** — put a printer "out of service" without removing it. Toggle from the card's three-dot menu, the in-card amber banner, or the Edit Printer dialog; the printer disconnects MQTT, drops out of queue dispatch, the scheduler, model-based filament lookups, metrics, and notifications until you take it out again. The card stays visible (amber wrench banner + Exit button) so the printer never disappears from your dashboard. Useful for parallel Bambuddy installs sharing the same hardware, printers under repair or awaiting parts, and temporary suspension.
 - Bulk printer actions (multi-select cards, then stop/pause/resume/clear all — select by state or location)
 - Printer search and filters — live search by name/model/location/serial plus status and location dropdown filters (WebSocket-reactive, mobile-friendly)
 - Resizable printer cards (S/M/L/XL)

+ 85 - 0
frontend/src/__tests__/pages/PrintersPage.test.tsx

@@ -19,6 +19,7 @@ const mockPrinters = [
     access_code: '12345678',
     model: 'X1C',
     enabled: true,
+    is_active: true,
     nozzle_diameter: 0.4,
     nozzle_type: 'hardened_steel',
     location: 'Workshop',
@@ -34,6 +35,7 @@ const mockPrinters = [
     access_code: '87654321',
     model: 'P1S',
     enabled: false,
+    is_active: true,
     nozzle_diameter: 0.4,
     nozzle_type: 'stainless_steel',
     location: null,
@@ -538,6 +540,89 @@ describe('PrintersPage', () => {
     });
   });
 
+  describe('maintenance mode (#1476)', () => {
+    // Wraps the backend is_active flag — already gates MQTT, queue dispatch,
+    // scheduler, metrics, picker. These tests pin the UI surface: status
+    // panel swap, pill swap, and the PATCH on toggle.
+    const inMaintenancePrinter = { ...mockPrinters[0], is_active: false };
+
+    it('shows the maintenance status panel instead of the print container', async () => {
+      server.use(
+        http.get('/api/v1/printers/', () => HttpResponse.json([inMaintenancePrinter])),
+        http.get('/api/v1/printers/:id/status', () =>
+          HttpResponse.json({ ...mockPrinterStatus, connected: false }),
+        ),
+      );
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('In Maintenance')).toBeInTheDocument();
+      });
+      // Exit button rendered
+      expect(screen.getByRole('button', { name: /exit maintenance/i })).toBeInTheDocument();
+      // The "No active job" / "Ready to print" copy from the normal status
+      // panel must NOT be present — confirms the swap, not a stacked render.
+      expect(screen.queryByText(/no active job/i)).not.toBeInTheDocument();
+      expect(screen.queryByText(/ready to print/i)).not.toBeInTheDocument();
+    });
+
+    it('shows the amber Maintenance pill in the header (no Connected/Offline)', async () => {
+      server.use(
+        http.get('/api/v1/printers/', () => HttpResponse.json([inMaintenancePrinter])),
+        http.get('/api/v1/printers/:id/status', () =>
+          HttpResponse.json({ ...mockPrinterStatus, connected: false }),
+        ),
+      );
+      render(<PrintersPage />);
+
+      // The header pill row contains "Maintenance" exactly once.
+      await waitFor(() => {
+        expect(screen.getAllByText('Maintenance').length).toBeGreaterThan(0);
+      });
+      // No connection diagnostic CTA (that's reserved for involuntary offline).
+      expect(screen.queryByRole('button', { name: /run.*diagnostic/i })).not.toBeInTheDocument();
+    });
+
+    it('PATCHes is_active=true when the Exit button is clicked', async () => {
+      const patchedBodies: unknown[] = [];
+      server.use(
+        http.get('/api/v1/printers/', () => HttpResponse.json([inMaintenancePrinter])),
+        http.get('/api/v1/printers/:id/status', () =>
+          HttpResponse.json({ ...mockPrinterStatus, connected: false }),
+        ),
+        http.patch('/api/v1/printers/:id', async ({ request }) => {
+          const body = await request.json();
+          patchedBodies.push(body);
+          return HttpResponse.json({ ...inMaintenancePrinter, is_active: true });
+        }),
+      );
+      render(<PrintersPage />);
+
+      const exit = await screen.findByRole('button', { name: /exit maintenance/i });
+      fireEvent.click(exit);
+
+      await waitFor(() => {
+        expect(patchedBodies.length).toBeGreaterThan(0);
+      });
+      expect(patchedBodies[0]).toEqual(expect.objectContaining({ is_active: true }));
+    });
+
+    it('renders the regular status panel when is_active=true', async () => {
+      server.use(
+        http.get('/api/v1/printers/', () => HttpResponse.json([{ ...mockPrinters[0], is_active: true }])),
+        http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockPrinterStatus)),
+      );
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+      });
+      // Active printer never shows the maintenance panel.
+      expect(screen.queryByText('In Maintenance')).not.toBeInTheDocument();
+      expect(screen.queryByRole('button', { name: /exit maintenance/i })).not.toBeInTheDocument();
+    });
+  });
+
   describe('nozzle rack card', () => {
     const h2cStatus = {
       ...mockPrinterStatus,

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

@@ -522,6 +522,10 @@ export interface PrinterCreate {
   model?: string;
   location?: string;
   auto_archive?: boolean;
+  // Maintenance Mode flag (#1476). Backend already gates MQTT, queue dispatch,
+  // scheduler, metrics and the print picker on this; toggling via PATCH
+  // /printers/{id} disconnects or reconnects MQTT accordingly.
+  is_active?: boolean;
   external_camera_url?: string | null;
   external_camera_type?: string | null;
   external_camera_enabled?: boolean;

+ 14 - 0
frontend/src/i18n/locales/de.ts

@@ -235,6 +235,20 @@ export default {
     },
     // Maintenance
     maintenanceUpToDate: 'Alle Wartungen aktuell - Klicken zum Anzeigen',
+    maintenance: {
+      title: 'In Wartung',
+      subtitle: 'Dieser Drucker ist pausiert — keine Verbindung, nicht für die Warteschlange verfügbar, keine Benachrichtigungen.',
+      pillLabel: 'Wartung',
+      exitButton: 'Wartung beenden',
+      menuEnter: 'In den Wartungsmodus wechseln',
+      menuExit: 'Wartungsmodus beenden',
+      toastEntered: '{{name}} ist jetzt im Wartungsmodus',
+      toastExited: '{{name}} ist wieder online',
+      confirmMidPrintTitle: 'Wartungsmodus während des Drucks aktivieren?',
+      confirmMidPrintMessage: '{{name}} druckt gerade. Der Wartungsmodus trennt die MQTT-Verbindung und beendet das Fortschritts-Tracking sowie Abschlussbenachrichtigungen für diesen Auftrag. Fortfahren?',
+      editFieldLabel: 'Wartungsmodus',
+      editFieldHelp: 'Wenn aktiviert, ist dieser Drucker von MQTT, Warteschlangenversand und Benachrichtigungen pausiert — nützlich für Reparaturen, parallele Bambuddy-Installationen oder temporäre Außerbetriebnahme.',
+    },
     // Chamber light
     chamberLightOn: 'Kammerbeleuchtung einschalten',
     chamberLightOff: 'Kammerbeleuchtung ausschalten',

+ 17 - 0
frontend/src/i18n/locales/en.ts

@@ -235,6 +235,23 @@ export default {
     },
     // Maintenance
     maintenanceUpToDate: 'All maintenance up to date - Click to view',
+    // Maintenance Mode (#1476) — operator-flipped "out of service" state.
+    // Distinct from the scheduled-maintenance dashboard above; this one
+    // wraps the backend is_active flag and stops MQTT + queue dispatch.
+    maintenance: {
+      title: 'In Maintenance',
+      subtitle: 'This printer is paused — not connected, not eligible for the queue, not sending notifications.',
+      pillLabel: 'Maintenance',
+      exitButton: 'Exit maintenance',
+      menuEnter: 'Enter maintenance mode',
+      menuExit: 'Exit maintenance mode',
+      toastEntered: '{{name}} is now in maintenance mode',
+      toastExited: '{{name}} is back online',
+      confirmMidPrintTitle: 'Enter maintenance mode mid-print?',
+      confirmMidPrintMessage: '{{name}} is currently printing. Entering maintenance mode will disconnect MQTT and stop progress tracking and completion notifications for this job. Continue?',
+      editFieldLabel: 'Maintenance mode',
+      editFieldHelp: 'When on, this printer is paused from MQTT, queue dispatch and notifications — useful for repair, parallel Bambuddy installs, or temporary suspension.',
+    },
     // Chamber light
     chamberLightOn: 'Turn on chamber light',
     chamberLightOff: 'Turn off chamber light',

+ 14 - 0
frontend/src/i18n/locales/es.ts

@@ -235,6 +235,20 @@ export default {
     },
     // Maintenance
     maintenanceUpToDate: 'Todo el mantenimiento al día - Haga clic para ver',
+    maintenance: {
+      title: 'En mantenimiento',
+      subtitle: 'Esta impresora está en pausa — sin conexión, no elegible para la cola y sin enviar notificaciones.',
+      pillLabel: 'Mantenimiento',
+      exitButton: 'Salir de mantenimiento',
+      menuEnter: 'Entrar en modo mantenimiento',
+      menuExit: 'Salir del modo mantenimiento',
+      toastEntered: '{{name}} ahora está en modo mantenimiento',
+      toastExited: '{{name}} vuelve a estar en línea',
+      confirmMidPrintTitle: '¿Entrar en modo mantenimiento durante una impresión?',
+      confirmMidPrintMessage: '{{name}} está imprimiendo actualmente. Entrar en modo mantenimiento desconectará MQTT y detendrá el seguimiento del progreso y las notificaciones de finalización para este trabajo. ¿Continuar?',
+      editFieldLabel: 'Modo mantenimiento',
+      editFieldHelp: 'Cuando está activado, esta impresora se pausa de MQTT, despacho de cola y notificaciones — útil para reparaciones, instalaciones paralelas de Bambuddy o suspensión temporal.',
+    },
     // Chamber light
     chamberLightOn: 'Encender luz de la cámara',
     chamberLightOff: 'Apagar luz de la cámara',

+ 14 - 0
frontend/src/i18n/locales/fr.ts

@@ -235,6 +235,20 @@ export default {
     },
     // Maintenance
     maintenanceUpToDate: 'Maintenance à jour - Cliquez pour voir',
+    maintenance: {
+      title: 'En maintenance',
+      subtitle: "Cette imprimante est en pause — non connectée, non éligible à la file d'attente, sans notifications.",
+      pillLabel: 'Maintenance',
+      exitButton: 'Quitter la maintenance',
+      menuEnter: 'Activer le mode maintenance',
+      menuExit: 'Quitter le mode maintenance',
+      toastEntered: '{{name}} est maintenant en mode maintenance',
+      toastExited: '{{name}} est de nouveau en ligne',
+      confirmMidPrintTitle: 'Passer en mode maintenance pendant une impression ?',
+      confirmMidPrintMessage: "{{name}} est en cours d'impression. Le mode maintenance déconnectera MQTT et arrêtera le suivi de progression ainsi que les notifications de fin pour ce travail. Continuer ?",
+      editFieldLabel: 'Mode maintenance',
+      editFieldHelp: "Quand activé, cette imprimante est mise en pause de MQTT, de la file d'attente et des notifications — utile pour les réparations, les installations Bambuddy parallèles ou une suspension temporaire.",
+    },
     // Chamber light
     chamberLightOn: 'Allumer la lumière de la chambre',
     chamberLightOff: 'Éteindre la lumière de la chambre',

+ 14 - 0
frontend/src/i18n/locales/it.ts

@@ -235,6 +235,20 @@ export default {
     },
     // Maintenance
     maintenanceUpToDate: 'Tutta la manutenzione aggiornata - Clicca per vedere',
+    maintenance: {
+      title: 'In manutenzione',
+      subtitle: 'Questa stampante è in pausa — non connessa, non idonea per la coda, nessuna notifica inviata.',
+      pillLabel: 'Manutenzione',
+      exitButton: 'Esci dalla manutenzione',
+      menuEnter: 'Entra in modalità manutenzione',
+      menuExit: 'Esci dalla modalità manutenzione',
+      toastEntered: '{{name}} è ora in modalità manutenzione',
+      toastExited: '{{name}} è di nuovo online',
+      confirmMidPrintTitle: 'Entrare in modalità manutenzione durante la stampa?',
+      confirmMidPrintMessage: '{{name}} sta attualmente stampando. Entrare in modalità manutenzione disconnetterà MQTT e fermerà il tracciamento del progresso e le notifiche di completamento per questo lavoro. Continuare?',
+      editFieldLabel: 'Modalità manutenzione',
+      editFieldHelp: 'Quando attivata, questa stampante è in pausa da MQTT, dispatch della coda e notifiche — utile per riparazioni, installazioni Bambuddy parallele o sospensione temporanea.',
+    },
     // Chamber light
     chamberLightOn: 'Accendi luce camera',
     chamberLightOff: 'Spegni luce camera',

+ 14 - 0
frontend/src/i18n/locales/ja.ts

@@ -234,6 +234,20 @@ export default {
     },
     // Maintenance
     maintenanceUpToDate: 'すべてのメンテナンスが最新です',
+    maintenance: {
+      title: 'メンテナンス中',
+      subtitle: 'このプリンターは一時停止中です — 未接続、キュー対象外、通知も送信されません。',
+      pillLabel: 'メンテナンス',
+      exitButton: 'メンテナンスを終了',
+      menuEnter: 'メンテナンスモードに入る',
+      menuExit: 'メンテナンスモードを終了',
+      toastEntered: '{{name}} はメンテナンスモードになりました',
+      toastExited: '{{name}} がオンラインに戻りました',
+      confirmMidPrintTitle: '印刷中にメンテナンスモードに入りますか?',
+      confirmMidPrintMessage: '{{name}} は現在印刷中です。メンテナンスモードに入るとMQTTが切断され、このジョブの進行状況の追跡と完了通知が停止します。続行しますか?',
+      editFieldLabel: 'メンテナンスモード',
+      editFieldHelp: '有効にすると、このプリンターはMQTT、キューディスパッチ、通知から一時停止されます — 修理、並列のBambuddyインストール、または一時的な停止に役立ちます。',
+    },
     // Chamber light
     chamberLightOn: 'チャンバーライトをオンにしました',
     chamberLightOff: 'チャンバーライトをオフにしました',

+ 14 - 0
frontend/src/i18n/locales/ko.ts

@@ -218,6 +218,20 @@ export default {
       excellent: '매우 좋음'
     },
     maintenanceUpToDate: '모든 유지보수 최신 상태 - 클릭하여 보기',
+    maintenance: {
+      title: '유지보수 중',
+      subtitle: '이 프린터는 일시 중지되었습니다 — 연결되지 않음, 대기열에서 제외, 알림 전송 안 함.',
+      pillLabel: '유지보수',
+      exitButton: '유지보수 종료',
+      menuEnter: '유지보수 모드로 전환',
+      menuExit: '유지보수 모드 종료',
+      toastEntered: '{{name}} 은(는) 이제 유지보수 모드입니다',
+      toastExited: '{{name}} 이(가) 다시 온라인 상태입니다',
+      confirmMidPrintTitle: '인쇄 중 유지보수 모드로 전환하시겠습니까?',
+      confirmMidPrintMessage: '{{name}} 은(는) 현재 인쇄 중입니다. 유지보수 모드로 전환하면 MQTT가 연결 해제되고 이 작업의 진행률 추적 및 완료 알림이 중지됩니다. 계속하시겠습니까?',
+      editFieldLabel: '유지보수 모드',
+      editFieldHelp: '활성화하면 이 프린터는 MQTT, 대기열 디스패치 및 알림에서 일시 중지됩니다 — 수리, 병렬 Bambuddy 설치 또는 임시 중지에 유용합니다.',
+    },
     chamberLightOn: '챔버 조명 켜기',
     chamberLightOff: '챔버 조명 끄기',
     files: '파일',

+ 14 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -235,6 +235,20 @@ export default {
     },
     // Maintenance
     maintenanceUpToDate: 'Toda a manutenção está em dia - Clique para ver',
+    maintenance: {
+      title: 'Em manutenção',
+      subtitle: 'Esta impressora está pausada — sem conexão, não elegível para a fila, sem enviar notificações.',
+      pillLabel: 'Manutenção',
+      exitButton: 'Sair da manutenção',
+      menuEnter: 'Entrar no modo manutenção',
+      menuExit: 'Sair do modo manutenção',
+      toastEntered: '{{name}} está agora em modo manutenção',
+      toastExited: '{{name}} está online novamente',
+      confirmMidPrintTitle: 'Entrar em modo manutenção durante uma impressão?',
+      confirmMidPrintMessage: '{{name}} está imprimindo no momento. Entrar em modo manutenção desconectará o MQTT e interromperá o acompanhamento do progresso e as notificações de conclusão para este trabalho. Continuar?',
+      editFieldLabel: 'Modo manutenção',
+      editFieldHelp: 'Quando ativado, esta impressora é pausada do MQTT, despacho de fila e notificações — útil para reparos, instalações paralelas do Bambuddy ou suspensão temporária.',
+    },
     // Chamber light
     chamberLightOn: 'Ligar luz da câmara',
     chamberLightOff: 'Desligar luz da câmara',

+ 14 - 0
frontend/src/i18n/locales/tr.ts

@@ -235,6 +235,20 @@ export default {
     },
     // Bakım
     maintenanceUpToDate: 'Tüm bakım güncel - Görüntülemek için tıklayın',
+    maintenance: {
+      title: 'Bakımda',
+      subtitle: 'Bu yazıcı duraklatıldı — bağlı değil, kuyruğa uygun değil, bildirim göndermiyor.',
+      pillLabel: 'Bakım',
+      exitButton: 'Bakımdan çık',
+      menuEnter: 'Bakım moduna gir',
+      menuExit: 'Bakım modundan çık',
+      toastEntered: '{{name}} artık bakım modunda',
+      toastExited: '{{name}} tekrar çevrimiçi',
+      confirmMidPrintTitle: 'Yazdırma sırasında bakım moduna geçilsin mi?',
+      confirmMidPrintMessage: '{{name}} şu anda yazdırıyor. Bakım moduna geçmek MQTT bağlantısını kesecek ve bu iş için ilerleme takibi ile tamamlanma bildirimlerini durduracak. Devam edilsin mi?',
+      editFieldLabel: 'Bakım modu',
+      editFieldHelp: 'Etkinleştirildiğinde, bu yazıcı MQTT, kuyruk gönderimi ve bildirimlerden duraklatılır — tamir, paralel Bambuddy kurulumları veya geçici askıya alma için kullanışlıdır.',
+    },
     // Hazne ışığı
     chamberLightOn: 'Hazne ışığını aç',
     chamberLightOff: 'Hazne ışığını kapat',

+ 14 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -235,6 +235,20 @@ export default {
     },
     // Maintenance
     maintenanceUpToDate: '所有维护均已完成 - 点击查看',
+    maintenance: {
+      title: '维护中',
+      subtitle: '此打印机已暂停 — 未连接、不参与队列调度、不发送通知。',
+      pillLabel: '维护',
+      exitButton: '退出维护',
+      menuEnter: '进入维护模式',
+      menuExit: '退出维护模式',
+      toastEntered: '{{name}} 已进入维护模式',
+      toastExited: '{{name}} 已恢复在线',
+      confirmMidPrintTitle: '在打印过程中进入维护模式?',
+      confirmMidPrintMessage: '{{name}} 当前正在打印。进入维护模式将断开 MQTT 连接,并停止此任务的进度跟踪和完成通知。是否继续?',
+      editFieldLabel: '维护模式',
+      editFieldHelp: '启用后,此打印机将从 MQTT、队列调度和通知中暂停 — 适用于维修、并行 Bambuddy 安装或临时停用。',
+    },
     // Chamber light
     chamberLightOn: '打开腔室灯',
     chamberLightOff: '关闭腔室灯',

+ 14 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -235,6 +235,20 @@ export default {
     },
     // Maintenance
     maintenanceUpToDate: '所有維護均已完成 - 點選檢視',
+    maintenance: {
+      title: '維護中',
+      subtitle: '此印表機已暫停 — 未連接、不參與佇列調度、不發送通知。',
+      pillLabel: '維護',
+      exitButton: '退出維護',
+      menuEnter: '進入維護模式',
+      menuExit: '退出維護模式',
+      toastEntered: '{{name}} 已進入維護模式',
+      toastExited: '{{name}} 已恢復在線',
+      confirmMidPrintTitle: '在列印過程中進入維護模式?',
+      confirmMidPrintMessage: '{{name}} 目前正在列印。進入維護模式將斷開 MQTT 連線,並停止此工作的進度追蹤和完成通知。是否繼續?',
+      editFieldLabel: '維護模式',
+      editFieldHelp: '啟用後,此印表機將從 MQTT、佇列調度和通知中暫停 — 適用於維修、並行 Bambuddy 安裝或暫時停用。',
+    },
     // Chamber light
     chamberLightOn: '開啟腔室燈',
     chamberLightOff: '關閉腔室燈',

+ 176 - 19
frontend/src/pages/PrintersPage.tsx

@@ -2507,6 +2507,38 @@ function PrinterCard({
     onError: (error: Error) => showToast(error.message || t('printers.toast.failedToUpdateSetting'), 'error'),
   });
 
+  // Maintenance mode toggle (#1476). Wraps the `is_active` backend field that
+  // already gates MQTT connection, queue dispatch, scheduler eligibility,
+  // metrics, and the print picker — so flipping this flag puts the printer
+  // out of service across every consumer in one place. Used from the
+  // overflow menu and EditPrinterModal.
+  const maintenanceMutation = useMutation({
+    mutationFn: (isActive: boolean) => api.updatePrinter(printer.id, { is_active: isActive }),
+    onSuccess: (_data, isActive) => {
+      queryClient.invalidateQueries({ queryKey: ['printers'] });
+      queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
+      showToast(
+        isActive
+          ? t('printers.maintenance.toastExited', { name: printer.name })
+          : t('printers.maintenance.toastEntered', { name: printer.name }),
+        'success',
+      );
+    },
+    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToUpdateSetting'), 'error'),
+  });
+
+  // Confirm before entering maintenance on a printing printer (entering mode
+  // disconnects MQTT, which stops progress tracking + completion notifications
+  // for the in-flight job).
+  const [confirmMaintenanceEnter, setConfirmMaintenanceEnter] = useState(false);
+  const handleEnterMaintenance = () => {
+    if (status?.state === 'RUNNING' || status?.state === 'PAUSE') {
+      setConfirmMaintenanceEnter(true);
+    } else {
+      maintenanceMutation.mutate(false);
+    }
+  };
+
   // Query for printable objects (for skip functionality)
   // Fetch when printing with 2+ objects OR when modal is open
   const isPrintingWithObjects = (status?.state === 'RUNNING' || status?.state === 'PAUSE') && (status?.printable_objects_count ?? 0) >= 2;
@@ -2996,6 +3028,30 @@ function PrinterCard({
             <Info className="w-4 h-4" />
             {t('printers.printerInformation')}
           </button>
+          {/* Maintenance Mode toggle (#1476) — leverages backend is_active flag */}
+          <button
+            className={`w-full px-4 py-2 text-left text-sm flex items-center gap-2 ${
+              hasPermission('printers:update')
+                ? 'hover:bg-bambu-dark-tertiary'
+                : 'opacity-50 cursor-not-allowed'
+            }`}
+            disabled={maintenanceMutation.isPending || !hasPermission('printers:update')}
+            onClick={() => {
+              if (!hasPermission('printers:update')) return;
+              setShowMenu(false);
+              if (printer.is_active !== false) {
+                handleEnterMaintenance();
+              } else {
+                maintenanceMutation.mutate(true);
+              }
+            }}
+            title={!hasPermission('printers:update') ? t('printers.permission.noEdit') : undefined}
+          >
+            <Wrench className="w-4 h-4" />
+            {printer.is_active !== false
+              ? t('printers.maintenance.menuEnter')
+              : t('printers.maintenance.menuExit')}
+          </button>
           <button
             className="w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2"
             onClick={() => {
@@ -3193,23 +3249,38 @@ function PrinterCard({
           {viewMode === 'expanded' && (
             <div className="mt-2">
               <div className="flex flex-wrap items-center gap-2">
-              {/* Connection status badge */}
-              <span
-                className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-xs ${
-                  status?.connected
-                    ? 'bg-status-ok/20 text-status-ok'
-                    : 'bg-status-error/20 text-status-error'
-                }`}
-              >
-                {status?.connected ? (
-                  <Link className="w-3 h-3" />
-                ) : (
-                  <Unlink className="w-3 h-3" />
-                )}
-                {status?.connected ? t('printers.connection.connected') : t('printers.connection.offline')}
-              </span>
-              {/* Run connection diagnostic — offered when the printer is offline */}
-              {!status?.connected && (
+              {/* Connection status badge (or Maintenance pill when out of service).
+                  Defensive: only swap when is_active is EXPLICITLY false. An
+                  undefined / missing field defaults to "active" so the regular
+                  pill renders — matches the backend default and prevents test
+                  fixtures (or stale clients) from accidentally tripping the
+                  maintenance UI. */}
+              {printer.is_active === false ? (
+                <span
+                  className="flex items-center gap-1.5 px-2 py-1 rounded-full text-xs bg-amber-500/20 text-amber-400"
+                  title={t('printers.maintenance.subtitle')}
+                >
+                  <Wrench className="w-3 h-3" />
+                  {t('printers.maintenance.pillLabel')}
+                </span>
+              ) : (
+                <span
+                  className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-xs ${
+                    status?.connected
+                      ? 'bg-status-ok/20 text-status-ok'
+                      : 'bg-status-error/20 text-status-error'
+                  }`}
+                >
+                  {status?.connected ? (
+                    <Link className="w-3 h-3" />
+                  ) : (
+                    <Unlink className="w-3 h-3" />
+                  )}
+                  {status?.connected ? t('printers.connection.connected') : t('printers.connection.offline')}
+                </span>
+              )}
+              {/* Run connection diagnostic — offered when the printer is offline, NOT in maintenance */}
+              {printer.is_active !== false && !status?.connected && (
                 <button
                   onClick={() => setShowDiagnostic(true)}
                   className="flex items-center gap-1 px-2 py-1 rounded-full text-xs cursor-pointer bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
@@ -3407,8 +3478,53 @@ function PrinterCard({
           </div>
         )}
 
-        {/* Status */}
-        {status?.connected && (
+        {/* Status — see the equivalent defensive `=== false` check on the
+            header pill above for why this is not `!printer.is_active`. */}
+        {printer.is_active === false ? (
+          // Maintenance mode (#1476) — replaces the cover/progress container
+          // so the card keeps the same height. Renders for both compact and
+          // expanded view modes so the printer stays visible but plainly
+          // out-of-service.
+          <>
+            {viewMode === 'compact' ? (
+              <div className="mt-2 flex items-center gap-2 px-2 py-1.5 rounded-full bg-amber-500/15 border border-amber-500/30">
+                <Wrench className="w-3 h-3 text-amber-400 shrink-0" />
+                <span className="text-[11px] text-amber-400 font-medium truncate">
+                  {t('printers.maintenance.pillLabel')}
+                </span>
+              </div>
+            ) : (
+              <>
+                <div className="flex items-center gap-2 mb-2">
+                  <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
+                    {t('printers.status.title', 'Status')}
+                  </span>
+                  <div className="flex-1 h-[2px] bg-bambu-dark-tertiary" />
+                </div>
+                <div className="p-3 bg-amber-500/10 border border-amber-500/30 rounded-[10px] flex items-center gap-3">
+                  <Wrench className="w-6 h-6 text-amber-400 shrink-0" />
+                  <div className="flex-1 min-w-0">
+                    <p className="text-sm text-amber-400 font-medium">
+                      {t('printers.maintenance.title')}
+                    </p>
+                    <p className="text-xs text-bambu-gray mt-0.5">
+                      {t('printers.maintenance.subtitle')}
+                    </p>
+                  </div>
+                  <Button
+                    variant="secondary"
+                    size="sm"
+                    disabled={maintenanceMutation.isPending || !hasPermission('printers:update')}
+                    onClick={() => maintenanceMutation.mutate(true)}
+                    title={!hasPermission('printers:update') ? t('printers.permission.noEdit') : undefined}
+                  >
+                    {t('printers.maintenance.exitButton')}
+                  </Button>
+                </div>
+              </>
+            )}
+          </>
+        ) : status?.connected && (
           <>
             {/* Compact: Simple status bar */}
             {viewMode === 'compact' ? (
@@ -5852,6 +5968,24 @@ function PrinterCard({
         />
       )}
 
+      {/* Maintenance Mode mid-print confirmation (#1476) — entering maintenance
+          disconnects MQTT, which stops progress tracking + completion
+          notifications for the in-flight job. Idle / FINISH / FAILED states
+          skip this dialog and toggle directly. */}
+      {confirmMaintenanceEnter && (
+        <ConfirmModal
+          title={t('printers.maintenance.confirmMidPrintTitle')}
+          message={t('printers.maintenance.confirmMidPrintMessage', { name: printer.name })}
+          confirmText={t('printers.maintenance.menuEnter')}
+          variant="danger"
+          onConfirm={() => {
+            maintenanceMutation.mutate(false);
+            setConfirmMaintenanceEnter(false);
+          }}
+          onCancel={() => setConfirmMaintenanceEnter(false)}
+        />
+      )}
+
       {/* Power Off Confirmation */}
       {showPowerOffConfirm && smartPlug && (
         <ConfirmModal
@@ -7038,6 +7172,7 @@ function EditPrinterModal({
     model: printer.model || '',
     location: printer.location || '',
     auto_archive: printer.auto_archive,
+    is_active: printer.is_active,
   });
 
   // Setup-time pre-flight — same warn-on-save as the Add-Printer dialog, so an
@@ -7071,6 +7206,7 @@ function EditPrinterModal({
       model: form.model || undefined,
       location: form.location || undefined,
       auto_archive: form.auto_archive,
+      is_active: form.is_active,
     };
     // Only include access_code if it was changed
     if (form.access_code) {
@@ -7211,6 +7347,27 @@ function EditPrinterModal({
                 {t('printers.modal.autoArchiveLabel')}
               </label>
             </div>
+            {/* Maintenance Mode toggle (#1476) — checkbox is the inverse of
+                is_active because the user-facing concept is "is this printer
+                in maintenance" not "is it active". */}
+            <div>
+              <div className="flex items-center gap-2">
+                <input
+                  type="checkbox"
+                  id="edit_maintenance_mode"
+                  checked={!form.is_active}
+                  onChange={(e) => setForm({ ...form, is_active: !e.target.checked })}
+                  className="rounded border-bambu-dark-tertiary bg-bambu-dark text-amber-400 focus:ring-amber-400"
+                />
+                <label htmlFor="edit_maintenance_mode" className="text-sm text-bambu-gray flex items-center gap-1.5">
+                  <Wrench className="w-3.5 h-3.5 text-amber-400" />
+                  {t('printers.maintenance.editFieldLabel')}
+                </label>
+              </div>
+              <p className="text-xs text-bambu-gray/70 mt-1 ml-6">
+                {t('printers.maintenance.editFieldHelp')}
+              </p>
+            </div>
             {saveWarning ? (
               <div className="rounded-lg bg-amber-500/10 border border-amber-500/30 p-3 space-y-3">
                 <div className="flex items-start gap-2">

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


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


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BsijWO1C.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-7mua31Zk.css">
+    <script type="module" crossorigin src="/assets/index-CShQfZib.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-aPHrdXng.css">
   </head>
   <body>
     <div id="root"></div>

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