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.
This commit is contained in:
maziggy
2026-06-28 12:39:55 +02:00
16 changed files with 425 additions and 19 deletions
+2
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -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)
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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">