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-21 11:08:06 +02:00
parent 6b517ddc63
commit 36a16b8ae4
19 changed files with 468 additions and 62 deletions
@@ -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,