mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
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:
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'チャンバーライトをオフにしました',
|
||||
|
||||
@@ -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: '파일',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '关闭腔室灯',
|
||||
|
||||
@@ -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: '關閉腔室燈',
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user