Browse Source

Add bulk printer actions toolbar (#825)

  Select multiple printer cards and apply bulk actions (stop, pause,
  resume, clear notifications, clear bed) from a floating toolbar.
  Selection shortcuts: Select All, Select by State (printing/paused/
  finished/idle/error/offline), Select by Location. Action buttons are
  smart-enabled based on selected printers' current states. Confirmation
  modals for destructive actions. The status summary bar now shows all
  printer states.
maziggy 1 month ago
parent
commit
3c887f5166

+ 1 - 0
CHANGELOG.md

@@ -10,6 +10,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Plate-Clear Confirmation Setting** ([#752](https://github.com/maziggy/bambuddy/issues/752)) — New "Require plate-clear confirmation" toggle in Settings → Queue. When disabled, the scheduler starts queued prints automatically on printers with finished jobs without waiting for per-printer plate confirmation. Useful for farm workflows where plates are verified physically before starting a batch. Default is enabled (existing behavior preserved). Requested by @UVCXanth.
 - **Plate-Clear Confirmation Setting** ([#752](https://github.com/maziggy/bambuddy/issues/752)) — New "Require plate-clear confirmation" toggle in Settings → Queue. When disabled, the scheduler starts queued prints automatically on printers with finished jobs without waiting for per-printer plate confirmation. Useful for farm workflows where plates are verified physically before starting a batch. Default is enabled (existing behavior preserved). Requested by @UVCXanth.
 - **Settings Queue Tab** — New dedicated Queue tab in Settings consolidates queue-related settings: staggered start defaults and auto-drying configuration (moved from the Filament tab).
 - **Settings Queue Tab** — New dedicated Queue tab in Settings consolidates queue-related settings: staggered start defaults and auto-drying configuration (moved from the Filament tab).
 - **Per-User Statistics Filtering** ([#730](https://github.com/maziggy/bambuddy/issues/730)) — Admins can now filter the Statistics page by user. A user dropdown appears in the stats header for users with the new `stats:filter_by_user` permission (Administrators only by default). Filter by a specific user to see their prints, filament usage, and costs, or select "No User (System)" to view prints without user attribution (e.g. slicer-initiated or pre-auth prints). The filter applies to all stats widgets and exports. Requested by @3823u44238.
 - **Per-User Statistics Filtering** ([#730](https://github.com/maziggy/bambuddy/issues/730)) — Admins can now filter the Statistics page by user. A user dropdown appears in the stats header for users with the new `stats:filter_by_user` permission (Administrators only by default). Filter by a specific user to see their prints, filament usage, and costs, or select "No User (System)" to view prints without user attribution (e.g. slicer-initiated or pre-auth prints). The filter applies to all stats widgets and exports. Requested by @3823u44238.
+- **Bulk Printer Actions** ([#825](https://github.com/maziggy/bambuddy/issues/825)) — Select multiple printer cards and apply bulk actions from a floating toolbar. Toggle selection mode from the header, then click cards to select. Use "Select All", "Select by State" (printing, paused, finished, idle, error, offline), or "Select by Location" to quickly pick printers. Available actions: Stop, Pause, Resume, Clear Notifications, and Clear Bed — each button is smart-enabled based on the selected printers' current states. Confirmation modals for destructive actions (Stop, Pause, Clear Bed). The status summary bar now shows all printer states (printing, paused, finished, idle, error, offline). Requested by @therevoman.
 
 
 ### Improved
 ### Improved
 - **Queue Page Visual Refresh** — Compact stats bar replaces the five summary cards (saves vertical space), color-coded left borders on all queue items for instant status scanning, collapsible history section (collapsed by default), and condensed single-line rows for history items showing more prints at a glance.
 - **Queue Page Visual Refresh** — Compact stats bar replaces the five summary cards (saves vertical space), color-coded left borders on all queue items for instant status scanning, collapsible history section (collapsed by default), and condensed single-line rows for history items showing more prints at a glance.

+ 1 - 0
README.md

@@ -86,6 +86,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment)
 - **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment)
 - Fan status monitoring (part cooling, auxiliary, chamber)
 - Fan status monitoring (part cooling, auxiliary, chamber)
 - Printer control (stop, pause, resume, chamber light, print speed)
 - Printer control (stop, pause, resume, chamber light, print speed)
+- Bulk printer actions (multi-select cards, then stop/pause/resume/clear all — select by state or location)
 - Resizable printer cards (S/M/L/XL)
 - Resizable printer cards (S/M/L/XL)
 - Skip objects during print
 - Skip objects during print
 - AMS slot RFID re-read
 - AMS slot RFID re-read

+ 92 - 1
frontend/src/__tests__/pages/PrintersPage.test.tsx

@@ -3,7 +3,7 @@
  */
  */
 
 
 import { describe, it, expect, beforeEach } from 'vitest';
 import { describe, it, expect, beforeEach } from 'vitest';
-import { screen, waitFor } from '@testing-library/react';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
 import { render } from '../utils';
 import { render } from '../utils';
 import { PrintersPage } from '../../pages/PrintersPage';
 import { PrintersPage } from '../../pages/PrintersPage';
 import { http, HttpResponse } from 'msw';
 import { http, HttpResponse } from 'msw';
@@ -385,4 +385,95 @@ describe('PrintersPage', () => {
       expect(screen.queryByText('01.01.03.00')).not.toBeInTheDocument();
       expect(screen.queryByText('01.01.03.00')).not.toBeInTheDocument();
     });
     });
   });
   });
+
+  describe('bulk selection', () => {
+    it('shows select button in toolbar', async () => {
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+      });
+
+      // The Select button should be in the toolbar (title attribute)
+      const selectButton = screen.getByTitle('Select');
+      expect(selectButton).toBeInTheDocument();
+    });
+
+    it('shows selection toolbar after clicking select button', async () => {
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+      });
+
+      // Click the Select button to enter selection mode
+      fireEvent.click(screen.getByTitle('Select'));
+
+      // The floating toolbar should appear with Select All
+      await waitFor(() => {
+        expect(screen.getByText('Select All')).toBeInTheDocument();
+      });
+    });
+
+    it('shows selection count when printers are selected', async () => {
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+      });
+
+      // Enter selection mode
+      fireEvent.click(screen.getByTitle('Select'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Select All')).toBeInTheDocument();
+      });
+
+      // Click Select All to select both printers
+      fireEvent.click(screen.getByText('Select All'));
+
+      // Should show "2 selected"
+      await waitFor(() => {
+        expect(screen.getByText('2 selected')).toBeInTheDocument();
+      });
+    });
+
+    it('shows select by state dropdown', async () => {
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+      });
+
+      // Enter selection mode
+      fireEvent.click(screen.getByTitle('Select'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Select by State')).toBeInTheDocument();
+      });
+    });
+
+    it('exits selection mode on close button', async () => {
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+      });
+
+      // Enter selection mode
+      fireEvent.click(screen.getByTitle('Select'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Select All')).toBeInTheDocument();
+      });
+
+      // Click the Select button again to exit (it toggles)
+      fireEvent.click(screen.getByTitle('Select'));
+
+      // Floating toolbar should disappear
+      await waitFor(() => {
+        expect(screen.queryByText('Select All')).not.toBeInTheDocument();
+      });
+    });
+  });
 });
 });

+ 268 - 0
frontend/src/components/BulkPrinterToolbar.tsx

@@ -0,0 +1,268 @@
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useQueryClient } from '@tanstack/react-query';
+import { useAuth } from '../contexts/AuthContext';
+import {
+  X,
+  Square,
+  Pause,
+  Play,
+  ChevronDown,
+  BellOff,
+  Eraser,
+} from 'lucide-react';
+import { Button } from './Button';
+import { filterKnownHMSErrors } from './HMSErrorModal';
+import type { Printer, HMSError } from '../api/client';
+
+export type BulkAction = 'stop' | 'pause' | 'resume' | 'clearPlate' | 'clearHMS';
+export type PrinterState = 'printing' | 'paused' | 'finished' | 'idle' | 'error' | 'offline';
+
+interface PrinterStatus {
+  connected: boolean;
+  state: string | null;
+  hms_errors?: HMSError[];
+  plate_cleared?: boolean;
+}
+
+interface BulkPrinterToolbarProps {
+  selectedIds: Set<number>;
+  printers: Printer[];
+  onClose: () => void;
+  onSelectAll: () => void;
+  onSelectByLocation: (location: string) => void;
+  onSelectByState: (state: PrinterState) => void;
+  onAction: (action: BulkAction) => void;
+  actionPending: boolean;
+}
+
+const STATE_OPTIONS: { key: PrinterState; dot: string }[] = [
+  { key: 'printing', dot: 'bg-bambu-green' },
+  { key: 'paused', dot: 'bg-status-warning' },
+  { key: 'finished', dot: 'bg-blue-400' },
+  { key: 'idle', dot: 'bg-bambu-green' },
+  { key: 'error', dot: 'bg-status-error' },
+  { key: 'offline', dot: 'bg-gray-400' },
+];
+
+export function BulkPrinterToolbar({
+  selectedIds,
+  printers,
+  onClose,
+  onSelectAll,
+  onSelectByLocation,
+  onSelectByState,
+  onAction,
+  actionPending,
+}: BulkPrinterToolbarProps) {
+  const { t } = useTranslation();
+  const { hasPermission } = useAuth();
+  const queryClient = useQueryClient();
+  const [showLocationDropdown, setShowLocationDropdown] = useState(false);
+  const [showStateDropdown, setShowStateDropdown] = useState(false);
+
+  // Read cached statuses for selected printers
+  const selectedStatuses = Array.from(selectedIds).map(id => ({
+    id,
+    status: queryClient.getQueryData<PrinterStatus>(['printerStatus', id]),
+  }));
+
+  // Smart enablement: check if any selected printer is in the right state
+  const anyRunning = selectedStatuses.some(
+    ({ status }) => status?.connected && status.state === 'RUNNING',
+  );
+  const anyPaused = selectedStatuses.some(
+    ({ status }) => status?.connected && status.state === 'PAUSE',
+  );
+  const anyStoppable = anyRunning || anyPaused;
+  const anyNeedsClearPlate = selectedStatuses.some(
+    ({ status }) => status?.connected && (status.state === 'FINISH' || status.state === 'FAILED') && !status.plate_cleared,
+  );
+  const anyWithHMS = selectedStatuses.some(({ status }) => {
+    if (!status?.connected || !status.hms_errors) return false;
+    return filterKnownHMSErrors(status.hms_errors).length > 0;
+  });
+
+  const canControl = hasPermission('printers:control');
+  const canClearPlate = hasPermission('printers:clear_plate');
+
+  // Unique locations from all printers (not just selected)
+  const locations = [...new Set(printers.map(p => p.location).filter((l): l is string => !!l))].sort();
+
+  // Count printers per state for the state dropdown
+  const stateCounts: Record<PrinterState, number> = { printing: 0, paused: 0, finished: 0, idle: 0, error: 0, offline: 0 };
+  printers.forEach(p => {
+    const status = queryClient.getQueryData<PrinterStatus>(['printerStatus', p.id]);
+    if (!status || !status.connected) { stateCounts.offline++; return; }
+    if (status.hms_errors && filterKnownHMSErrors(status.hms_errors).length > 0) stateCounts.error++;
+    switch (status.state) {
+      case 'RUNNING': stateCounts.printing++; break;
+      case 'PAUSE': stateCounts.paused++; break;
+      case 'FINISH': stateCounts.finished++; break;
+      case 'FAILED': stateCounts.error++; break;
+      default: stateCounts.idle++; break;
+    }
+  });
+
+  const stateLabels: Record<PrinterState, string> = {
+    printing: t('printers.status.printing'),
+    paused: t('printers.status.paused', 'Paused'),
+    finished: t('printers.status.finished', 'Finished'),
+    idle: t('printers.status.available'),
+    error: t('printers.status.problem'),
+    offline: t('printers.status.offline'),
+  };
+
+  return (
+    <div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl px-4 py-3 flex items-center gap-3 flex-wrap">
+      {/* Close */}
+      <Button variant="secondary" size="sm" onClick={onClose}>
+        <X className="w-4 h-4" />
+      </Button>
+
+      <div className="w-px h-6 bg-bambu-dark-tertiary" />
+
+      {/* Selection count */}
+      <span className="text-white font-medium text-sm">
+        {t('printers.bulk.selected', { count: selectedIds.size })}
+      </span>
+
+      <div className="w-px h-6 bg-bambu-dark-tertiary" />
+
+      {/* Select All */}
+      <Button variant="secondary" size="sm" onClick={onSelectAll}>
+        {t('printers.bulk.selectAll')}
+      </Button>
+
+      {/* Select by State */}
+      <div className="relative">
+        <Button
+          variant="secondary"
+          size="sm"
+          onClick={() => { setShowStateDropdown(!showStateDropdown); setShowLocationDropdown(false); }}
+        >
+          {t('printers.bulk.selectByState')}
+          <ChevronDown className={`w-3 h-3 transition-transform ${showStateDropdown ? 'rotate-180' : ''}`} />
+        </Button>
+        {showStateDropdown && (
+          <>
+            <div
+              className="fixed inset-0 z-10"
+              onClick={() => setShowStateDropdown(false)}
+            />
+            <div className="absolute bottom-full mb-2 left-0 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-20 py-1">
+              {STATE_OPTIONS.filter(({ key }) => stateCounts[key] > 0).map(({ key, dot }) => (
+                <button
+                  key={key}
+                  onClick={() => {
+                    onSelectByState(key);
+                    setShowStateDropdown(false);
+                  }}
+                  className="w-full text-left px-3 py-2 text-sm text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white transition-colors flex items-center gap-2"
+                >
+                  <div className={`w-2 h-2 rounded-full ${dot}`} />
+                  {stateLabels[key]}
+                  <span className="ml-auto text-bambu-gray text-xs">{stateCounts[key]}</span>
+                </button>
+              ))}
+            </div>
+          </>
+        )}
+      </div>
+
+      {/* Select by Location */}
+      {locations.length > 0 && (
+        <div className="relative">
+          <Button
+            variant="secondary"
+            size="sm"
+            onClick={() => { setShowLocationDropdown(!showLocationDropdown); setShowStateDropdown(false); }}
+          >
+            {t('printers.bulk.selectByLocation')}
+            <ChevronDown className={`w-3 h-3 transition-transform ${showLocationDropdown ? 'rotate-180' : ''}`} />
+          </Button>
+          {showLocationDropdown && (
+            <>
+              <div
+                className="fixed inset-0 z-10"
+                onClick={() => setShowLocationDropdown(false)}
+              />
+              <div className="absolute bottom-full mb-2 left-0 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-20 py-1">
+                {locations.map(location => (
+                  <button
+                    key={location}
+                    onClick={() => {
+                      onSelectByLocation(location);
+                      setShowLocationDropdown(false);
+                    }}
+                    className="w-full text-left px-3 py-2 text-sm text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white transition-colors"
+                  >
+                    {location}
+                  </button>
+                ))}
+              </div>
+            </>
+          )}
+        </div>
+      )}
+
+      <div className="w-px h-6 bg-bambu-dark-tertiary" />
+
+      {/* Action buttons */}
+      <Button
+        size="sm"
+        className="bg-red-500 hover:bg-red-600"
+        onClick={() => onAction('stop')}
+        disabled={actionPending || !canControl || !anyStoppable}
+        title={!canControl ? t('printers.permission.noControl') : !anyStoppable ? t('printers.bulk.noneApplicable') : undefined}
+      >
+        <Square className="w-3.5 h-3.5" />
+        {t('printers.bulk.actions.stop')}
+      </Button>
+
+      <Button
+        variant="secondary"
+        size="sm"
+        onClick={() => onAction('pause')}
+        disabled={actionPending || !canControl || !anyRunning}
+        title={!canControl ? t('printers.permission.noControl') : !anyRunning ? t('printers.bulk.noneApplicable') : undefined}
+      >
+        <Pause className="w-3.5 h-3.5" />
+        {t('printers.bulk.actions.pause')}
+      </Button>
+
+      <Button
+        variant="secondary"
+        size="sm"
+        onClick={() => onAction('resume')}
+        disabled={actionPending || !canControl || !anyPaused}
+        title={!canControl ? t('printers.permission.noControl') : !anyPaused ? t('printers.bulk.noneApplicable') : undefined}
+      >
+        <Play className="w-3.5 h-3.5" />
+        {t('printers.bulk.actions.resume')}
+      </Button>
+
+      <Button
+        variant="secondary"
+        size="sm"
+        onClick={() => onAction('clearHMS')}
+        disabled={actionPending || !canControl || !anyWithHMS}
+        title={!canControl ? t('printers.permission.noControl') : !anyWithHMS ? t('printers.bulk.noneApplicable') : undefined}
+      >
+        <BellOff className="w-3.5 h-3.5" />
+        {t('printers.bulk.actions.clearHMS')}
+      </Button>
+
+      <Button
+        variant="secondary"
+        size="sm"
+        onClick={() => onAction('clearPlate')}
+        disabled={actionPending || !canClearPlate || !anyNeedsClearPlate}
+        title={!canClearPlate ? t('printers.permission.noControl') : !anyNeedsClearPlate ? t('printers.bulk.noneApplicable') : undefined}
+      >
+        <Eraser className="w-3.5 h-3.5" />
+        {t('printers.bulk.actions.clearPlate')}
+      </Button>
+    </div>
+  );
+}

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

@@ -383,6 +383,35 @@ export default {
       powerOffWarning: 'WARNUNG: "{{name}}" druckt gerade! Möchten Sie die Stromversorgung wirklich AUSSCHALTEN? Dies unterbricht den Druck und kann den Drucker beschädigen.',
       powerOffWarning: 'WARNUNG: "{{name}}" druckt gerade! Möchten Sie die Stromversorgung wirklich AUSSCHALTEN? Dies unterbricht den Druck und kann den Drucker beschädigen.',
       powerOffButton: 'Ausschalten',
       powerOffButton: 'Ausschalten',
     },
     },
+    // Bulk actions
+    bulk: {
+      select: 'Auswählen',
+      selectAll: 'Alle auswählen',
+      selectByLocation: 'Nach Standort auswählen',
+      selected: '{{count}} ausgewählt',
+      actions: {
+        stop: 'Stoppen',
+        pause: 'Pausieren',
+        resume: 'Fortsetzen',
+        clearPlate: 'Druckbett leeren',
+        clearHMS: 'Benachrichtigungen löschen',
+      },
+      confirm: {
+        stopTitle: '{{count}} Drucke stoppen',
+        stopMessage: 'Dies wird aktive Drucke auf {{count}} Drucker(n) abbrechen. Diese Aktion kann nicht rückgängig gemacht werden.',
+        stopButton: 'Alle stoppen',
+        pauseTitle: '{{count}} Drucke pausieren',
+        pauseMessage: 'Dies wird aktive Drucke auf {{count}} Drucker(n) pausieren.',
+        pauseButton: 'Alle pausieren',
+        clearPlateTitle: '{{count}} Druckbetten leeren',
+        clearPlateMessage: 'Dies wird das Druckbett auf {{count}} Drucker(n) leeren und kann wartende Aufträge starten.',
+        clearPlateButton: 'Alle leeren',
+      },
+      success: '{{action}} auf {{count}} Drucker(n) abgeschlossen',
+      partial: '{{succeeded}} erfolgreich, {{failed}} fehlgeschlagen',
+      noneApplicable: 'Keine ausgewählten Drucker sind im richtigen Zustand für diese Aktion',
+      selectByState: 'Nach Status auswählen',
+    },
     // Discovery
     // Discovery
     discovery: {
     discovery: {
       title: 'Drucker entdecken',
       title: 'Drucker entdecken',

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

@@ -383,6 +383,35 @@ export default {
       powerOffWarning: 'WARNING: "{{name}}" is currently printing! Are you sure you want to turn OFF the power? This will interrupt the print and may damage the printer.',
       powerOffWarning: 'WARNING: "{{name}}" is currently printing! Are you sure you want to turn OFF the power? This will interrupt the print and may damage the printer.',
       powerOffButton: 'Power Off',
       powerOffButton: 'Power Off',
     },
     },
+    // Bulk actions
+    bulk: {
+      select: 'Select',
+      selectAll: 'Select All',
+      selectByLocation: 'Select by Location',
+      selected: '{{count}} selected',
+      actions: {
+        stop: 'Stop',
+        pause: 'Pause',
+        resume: 'Resume',
+        clearPlate: 'Clear Bed',
+        clearHMS: 'Clear Notifications',
+      },
+      confirm: {
+        stopTitle: 'Stop {{count}} Prints',
+        stopMessage: 'This will cancel active prints on {{count}} printer(s). This action cannot be undone.',
+        stopButton: 'Stop All',
+        pauseTitle: 'Pause {{count}} Prints',
+        pauseMessage: 'This will pause active prints on {{count}} printer(s).',
+        pauseButton: 'Pause All',
+        clearPlateTitle: 'Clear {{count}} Print Beds',
+        clearPlateMessage: 'This will clear the print bed on {{count}} printer(s) and may trigger queued jobs.',
+        clearPlateButton: 'Clear All',
+      },
+      success: '{{action}} completed on {{count}} printer(s)',
+      partial: '{{succeeded}} succeeded, {{failed}} failed',
+      noneApplicable: 'No selected printers are in the right state for this action',
+      selectByState: 'Select by State',
+    },
     // Discovery
     // Discovery
     discovery: {
     discovery: {
       title: 'Discover Printers',
       title: 'Discover Printers',

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

@@ -383,6 +383,35 @@ export default {
       powerOffWarning: 'ATTENTION : "{{name}}" imprime ! L\'éteindre maintenant peut endommager l\'imprimante.',
       powerOffWarning: 'ATTENTION : "{{name}}" imprime ! L\'éteindre maintenant peut endommager l\'imprimante.',
       powerOffButton: 'Éteindre',
       powerOffButton: 'Éteindre',
     },
     },
+    // Bulk actions
+    bulk: {
+      select: 'Sélectionner',
+      selectAll: 'Tout sélectionner',
+      selectByLocation: 'Sélectionner par emplacement',
+      selected: '{{count}} sélectionné(s)',
+      actions: {
+        stop: 'Arrêter',
+        pause: 'Pause',
+        resume: 'Reprendre',
+        clearPlate: 'Vider le plateau',
+        clearHMS: 'Effacer les notifications',
+      },
+      confirm: {
+        stopTitle: 'Arrêter {{count}} impressions',
+        stopMessage: 'Cela annulera les impressions actives sur {{count}} imprimante(s). Cette action est irréversible.',
+        stopButton: 'Tout arrêter',
+        pauseTitle: 'Mettre en pause {{count}} impressions',
+        pauseMessage: 'Cela mettra en pause les impressions actives sur {{count}} imprimante(s).',
+        pauseButton: 'Tout mettre en pause',
+        clearPlateTitle: 'Vider {{count}} plateaux',
+        clearPlateMessage: 'Cela videra le plateau sur {{count}} imprimante(s) et pourrait lancer les travaux en file d\'attente.',
+        clearPlateButton: 'Tout vider',
+      },
+      success: '{{action}} terminé sur {{count}} imprimante(s)',
+      partial: '{{succeeded}} réussi(s), {{failed}} échoué(s)',
+      noneApplicable: 'Aucune imprimante sélectionnée n\'est dans le bon état pour cette action',
+      selectByState: 'Sélectionner par état',
+    },
     // Discovery
     // Discovery
     discovery: {
     discovery: {
       title: 'Découvrir les imprimantes',
       title: 'Découvrir les imprimantes',

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

@@ -383,6 +383,35 @@ export default {
       powerOffWarning: 'AVVISO: "{{name}}" sta stampando! Sei sicuro di spegnere? Questo interromperà la stampa e potrebbe danneggiare la stampante.',
       powerOffWarning: 'AVVISO: "{{name}}" sta stampando! Sei sicuro di spegnere? Questo interromperà la stampa e potrebbe danneggiare la stampante.',
       powerOffButton: 'Spegni',
       powerOffButton: 'Spegni',
     },
     },
+    // Bulk actions
+    bulk: {
+      select: 'Seleziona',
+      selectAll: 'Seleziona tutto',
+      selectByLocation: 'Seleziona per posizione',
+      selected: '{{count}} selezionato/i',
+      actions: {
+        stop: 'Ferma',
+        pause: 'Pausa',
+        resume: 'Riprendi',
+        clearPlate: 'Svuota piano',
+        clearHMS: 'Cancella notifiche',
+      },
+      confirm: {
+        stopTitle: 'Ferma {{count}} stampe',
+        stopMessage: 'Questo annullerà le stampe attive su {{count}} stampante/i. Questa azione non può essere annullata.',
+        stopButton: 'Ferma tutte',
+        pauseTitle: 'Pausa {{count}} stampe',
+        pauseMessage: 'Questo metterà in pausa le stampe attive su {{count}} stampante/i.',
+        pauseButton: 'Pausa tutte',
+        clearPlateTitle: 'Svuota {{count}} piani di stampa',
+        clearPlateMessage: 'Questo svuoterà il piano di stampa su {{count}} stampante/i e potrebbe avviare i lavori in coda.',
+        clearPlateButton: 'Svuota tutti',
+      },
+      success: '{{action}} completato su {{count}} stampante/i',
+      partial: '{{succeeded}} riuscito/i, {{failed}} fallito/i',
+      noneApplicable: 'Nessuna stampante selezionata è nello stato corretto per questa azione',
+      selectByState: 'Seleziona per stato',
+    },
     // Discovery
     // Discovery
     discovery: {
     discovery: {
       title: 'Trova Stampanti',
       title: 'Trova Stampanti',

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

@@ -382,6 +382,35 @@ export default {
       powerOffWarning: '警告: 「{{name}}」は現在印刷中です!電源をオフにしますか?印刷が中断され、プリンターが損傷する可能性があります。',
       powerOffWarning: '警告: 「{{name}}」は現在印刷中です!電源をオフにしますか?印刷が中断され、プリンターが損傷する可能性があります。',
       powerOffButton: '電源オフ',
       powerOffButton: '電源オフ',
     },
     },
+    // Bulk actions
+    bulk: {
+      select: '選択',
+      selectAll: 'すべて選択',
+      selectByLocation: '場所で選択',
+      selected: '{{count}}件選択中',
+      actions: {
+        stop: '停止',
+        pause: '一時停止',
+        resume: '再開',
+        clearPlate: 'ベッドをクリア',
+        clearHMS: '通知をクリア',
+      },
+      confirm: {
+        stopTitle: '{{count}}件の印刷を停止',
+        stopMessage: '{{count}}台のプリンターのアクティブな印刷をキャンセルします。この操作は元に戻せません。',
+        stopButton: 'すべて停止',
+        pauseTitle: '{{count}}件の印刷を一時停止',
+        pauseMessage: '{{count}}台のプリンターのアクティブな印刷を一時停止します。',
+        pauseButton: 'すべて一時停止',
+        clearPlateTitle: '{{count}}台のプリントベッドをクリア',
+        clearPlateMessage: '{{count}}台のプリンターのプリントベッドをクリアし、キュー内のジョブが開始される場合があります。',
+        clearPlateButton: 'すべてクリア',
+      },
+      success: '{{count}}台のプリンターで{{action}}が完了',
+      partial: '{{succeeded}}件成功、{{failed}}件失敗',
+      noneApplicable: '選択したプリンターにこのアクションに適した状態のものがありません',
+      selectByState: 'ステータスで選択',
+    },
     // Discovery
     // Discovery
     discovery: {
     discovery: {
       title: 'プリンター',
       title: 'プリンター',

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

@@ -383,6 +383,35 @@ export default {
       powerOffWarning: 'AVISO: "{{name}}" está imprimindo no momento! Tem certeza de que deseja desligar a impressora? Isso interromperá a impressão e pode danificar a impressora.',
       powerOffWarning: 'AVISO: "{{name}}" está imprimindo no momento! Tem certeza de que deseja desligar a impressora? Isso interromperá a impressão e pode danificar a impressora.',
       powerOffButton: 'Desligar',
       powerOffButton: 'Desligar',
     },
     },
+    // Bulk actions
+    bulk: {
+      select: 'Selecionar',
+      selectAll: 'Selecionar tudo',
+      selectByLocation: 'Selecionar por local',
+      selected: '{{count}} selecionado(s)',
+      actions: {
+        stop: 'Parar',
+        pause: 'Pausar',
+        resume: 'Retomar',
+        clearPlate: 'Limpar mesa',
+        clearHMS: 'Limpar notificações',
+      },
+      confirm: {
+        stopTitle: 'Parar {{count}} impressões',
+        stopMessage: 'Isso cancelará as impressões ativas em {{count}} impressora(s). Esta ação não pode ser desfeita.',
+        stopButton: 'Parar todas',
+        pauseTitle: 'Pausar {{count}} impressões',
+        pauseMessage: 'Isso pausará as impressões ativas em {{count}} impressora(s).',
+        pauseButton: 'Pausar todas',
+        clearPlateTitle: 'Limpar {{count}} mesas de impressão',
+        clearPlateMessage: 'Isso limpará a mesa de impressão em {{count}} impressora(s) e pode iniciar trabalhos na fila.',
+        clearPlateButton: 'Limpar todas',
+      },
+      success: '{{action}} concluído em {{count}} impressora(s)',
+      partial: '{{succeeded}} bem-sucedido(s), {{failed}} falhou/falharam',
+      noneApplicable: 'Nenhuma impressora selecionada está no estado correto para esta ação',
+      selectByState: 'Selecionar por estado',
+    },
     // Discovery
     // Discovery
     discovery: {
     discovery: {
       title: 'Descobrir Impressoras',
       title: 'Descobrir Impressoras',

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

@@ -383,6 +383,35 @@ export default {
       powerOffWarning: '警告:"{{name}}"正在打印中!确定要关闭电源吗?这将中断打印并可能损坏打印机。',
       powerOffWarning: '警告:"{{name}}"正在打印中!确定要关闭电源吗?这将中断打印并可能损坏打印机。',
       powerOffButton: '关机',
       powerOffButton: '关机',
     },
     },
+    // Bulk actions
+    bulk: {
+      select: '选择',
+      selectAll: '全选',
+      selectByLocation: '按位置选择',
+      selected: '已选择{{count}}台',
+      actions: {
+        stop: '停止',
+        pause: '暂停',
+        resume: '继续',
+        clearPlate: '清除打印床',
+        clearHMS: '清除通知',
+      },
+      confirm: {
+        stopTitle: '停止{{count}}个打印任务',
+        stopMessage: '这将取消{{count}}台打印机上的活动打印任务。此操作无法撤销。',
+        stopButton: '全部停止',
+        pauseTitle: '暂停{{count}}个打印任务',
+        pauseMessage: '这将暂停{{count}}台打印机上的活动打印任务。',
+        pauseButton: '全部暂停',
+        clearPlateTitle: '清除{{count}}个打印床',
+        clearPlateMessage: '这将清除{{count}}台打印机的打印床,可能会触发队列中的任务。',
+        clearPlateButton: '全部清除',
+      },
+      success: '{{action}}已在{{count}}台打印机上完成',
+      partial: '{{succeeded}}成功,{{failed}}失败',
+      noneApplicable: '没有选中的打印机处于适合此操作的状态',
+      selectByState: '按状态选择',
+    },
     // Discovery
     // Discovery
     discovery: {
     discovery: {
       title: '发现打印机',
       title: '发现打印机',

+ 271 - 41
frontend/src/pages/PrintersPage.tsx

@@ -39,6 +39,7 @@ import {
   Download,
   Download,
   ScanSearch,
   ScanSearch,
   CheckCircle,
   CheckCircle,
+  CheckSquare,
   XCircle,
   XCircle,
   User,
   User,
   Home,
   Home,
@@ -56,6 +57,7 @@ import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdate
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
+import { BulkPrinterToolbar, type PrinterState } from '../components/BulkPrinterToolbar';
 import { FileManagerModal } from '../components/FileManagerModal';
 import { FileManagerModal } from '../components/FileManagerModal';
 import { EmbeddedCameraViewer } from '../components/EmbeddedCameraViewer';
 import { EmbeddedCameraViewer } from '../components/EmbeddedCameraViewer';
 import { MQTTDebugModal } from '../components/MQTTDebugModal';
 import { MQTTDebugModal } from '../components/MQTTDebugModal';
@@ -1120,10 +1122,12 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
 
 
   const { counts, nextFinish } = useMemo(() => {
   const { counts, nextFinish } = useMemo(() => {
     let printing = 0;
     let printing = 0;
+    let paused = 0;
+    let finished = 0;
     let idle = 0;
     let idle = 0;
     let offline = 0;
     let offline = 0;
     let loading = 0;
     let loading = 0;
-    let problem = 0;
+    let error = 0;
     let nextPrinterName: string | null = null;
     let nextPrinterName: string | null = null;
     let nextRemainingMin: number | null = null;
     let nextRemainingMin: number | null = null;
     let nextProgress: number = 0;
     let nextProgress: number = 0;
@@ -1138,25 +1142,37 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
       } else {
       } else {
         // Count printers with HMS errors
         // Count printers with HMS errors
         if (status.hms_errors && filterKnownHMSErrors(status.hms_errors).length > 0) {
         if (status.hms_errors && filterKnownHMSErrors(status.hms_errors).length > 0) {
-          problem++;
+          error++;
         }
         }
-        if (status.state === 'RUNNING') {
-          printing++;
-          if (status.remaining_time != null && status.remaining_time > 0) {
-            if (nextRemainingMin === null || status.remaining_time < nextRemainingMin) {
-              nextRemainingMin = status.remaining_time;
-              nextPrinterName = printer.name;
-              nextProgress = status.progress || 0;
+        switch (status.state) {
+          case 'RUNNING':
+            printing++;
+            if (status.remaining_time != null && status.remaining_time > 0) {
+              if (nextRemainingMin === null || status.remaining_time < nextRemainingMin) {
+                nextRemainingMin = status.remaining_time;
+                nextPrinterName = printer.name;
+                nextProgress = status.progress || 0;
+              }
             }
             }
-          }
-        } else {
-          idle++;
+            break;
+          case 'PAUSE':
+            paused++;
+            break;
+          case 'FINISH':
+            finished++;
+            break;
+          case 'FAILED':
+            error++;
+            break;
+          default:
+            idle++;
+            break;
         }
         }
       }
       }
     });
     });
 
 
     return {
     return {
-      counts: { printing, idle, offline, loading, problem, total: (printers?.length || 0) },
+      counts: { printing, paused, finished, idle, offline, loading, error, total: (printers?.length || 0) },
       nextFinish: nextPrinterName && nextRemainingMin ? { name: nextPrinterName, remainingMin: nextRemainingMin, progress: nextProgress } : null,
       nextFinish: nextPrinterName && nextRemainingMin ? { name: nextPrinterName, remainingMin: nextRemainingMin, progress: nextProgress } : null,
     };
     };
     // eslint-disable-next-line react-hooks/exhaustive-deps
     // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -1164,38 +1180,25 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
 
 
   if (!printers?.length) return null;
   if (!printers?.length) return null;
 
 
+  const badges: { count: number; dot: string; label: string }[] = [
+    { count: counts.printing, dot: 'bg-bambu-green animate-pulse', label: t('printers.status.printing').toLowerCase() },
+    { count: counts.paused, dot: 'bg-status-warning', label: t('printers.status.paused', 'paused').toLowerCase() },
+    { count: counts.finished, dot: 'bg-blue-400', label: t('printers.status.finished', 'finished').toLowerCase() },
+    { count: counts.idle, dot: counts.idle > 0 ? 'bg-bambu-green' : 'bg-gray-500', label: t('printers.status.available').toLowerCase() },
+    { count: counts.error, dot: 'bg-status-error', label: t('printers.status.problem').toLowerCase() },
+    { count: counts.offline, dot: 'bg-gray-400', label: t('printers.status.offline').toLowerCase() },
+  ];
+
   return (
   return (
     <div className="flex flex-wrap items-center gap-4 gap-y-2 text-sm">
     <div className="flex flex-wrap items-center gap-4 gap-y-2 text-sm">
-      <div className="flex items-center gap-1.5">
-        <div className={`w-2 h-2 rounded-full ${counts.idle > 0 ? 'bg-bambu-green' : 'bg-gray-500'}`} />
-        <span className="text-bambu-gray">
-          <span className="text-white font-medium">{counts.idle}</span> {t('printers.status.available').toLowerCase()}
-        </span>
-      </div>
-      {counts.printing > 0 && (
-        <div className="flex items-center gap-1.5">
-          <div className="w-2 h-2 rounded-full bg-bambu-green animate-pulse" />
-          <span className="text-bambu-gray">
-            <span className="text-white font-medium">{counts.printing}</span> {t('printers.status.printing').toLowerCase()}
-          </span>
-        </div>
-      )}
-      {counts.offline > 0 && (
-        <div className="flex items-center gap-1.5">
-          <div className="w-2 h-2 rounded-full bg-gray-400" />
+      {badges.map(({ count, dot, label }) => count > 0 && (
+        <div key={label} className="flex items-center gap-1.5">
+          <div className={`w-2 h-2 rounded-full ${dot}`} />
           <span className="text-bambu-gray">
           <span className="text-bambu-gray">
-            <span className="text-white font-medium">{counts.offline}</span> {t('printers.status.offline').toLowerCase()}
+            <span className="text-white font-medium">{count}</span> {label}
           </span>
           </span>
         </div>
         </div>
-      )}
-      {counts.problem > 0 && (
-        <div className="flex items-center gap-1.5">
-          <div className="w-2 h-2 rounded-full bg-status-error" />
-          <span className="text-bambu-gray">
-            <span className="text-white font-medium">{counts.problem}</span> {t('printers.status.problem').toLowerCase()}
-          </span>
-        </div>
-      )}
+      ))}
       {nextFinish && (
       {nextFinish && (
         <>
         <>
           <div className="w-px h-4 bg-bambu-dark-tertiary" />
           <div className="w-px h-4 bg-bambu-dark-tertiary" />
@@ -1517,6 +1520,9 @@ function PrinterCard({
   onOpenEmbeddedCamera,
   onOpenEmbeddedCamera,
   checkPrinterFirmware = true,
   checkPrinterFirmware = true,
   dryingPresets = DRYING_PRESETS,
   dryingPresets = DRYING_PRESETS,
+  selectionMode = false,
+  isSelected = false,
+  onToggleSelect,
 }: {
 }: {
   printer: Printer;
   printer: Printer;
   hideIfDisconnected?: boolean;
   hideIfDisconnected?: boolean;
@@ -1542,6 +1548,9 @@ function PrinterCard({
   onOpenEmbeddedCamera?: (printerId: number, printerName: string) => void;
   onOpenEmbeddedCamera?: (printerId: number, printerName: string) => void;
   checkPrinterFirmware?: boolean;
   checkPrinterFirmware?: boolean;
   dryingPresets?: Record<string, { n3f: number; n3s: number; n3f_hours: number; n3s_hours: number }>;
   dryingPresets?: Record<string, { n3f: number; n3s: number; n3f_hours: number; n3s_hours: number }>;
+  selectionMode?: boolean;
+  isSelected?: boolean;
+  onToggleSelect?: (id: number) => void;
 }) {
 }) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
@@ -2340,12 +2349,25 @@ function PrinterCard({
 
 
   return (
   return (
     <Card
     <Card
-      className="relative"
+      className={`relative ${isSelected ? 'ring-2 ring-bambu-green' : ''} ${selectionMode ? 'cursor-pointer' : ''}`}
       onDragEnter={handleCardDragEnter}
       onDragEnter={handleCardDragEnter}
       onDragOver={handleCardDragOver}
       onDragOver={handleCardDragOver}
       onDragLeave={handleCardDragLeave}
       onDragLeave={handleCardDragLeave}
       onDrop={handleCardDrop}
       onDrop={handleCardDrop}
     >
     >
+      {/* Selection mode click overlay — captures all clicks, preventing nested interactions */}
+      {selectionMode && (
+        <div
+          className="absolute inset-0 z-20 flex items-start p-2"
+          onClick={(e) => { e.stopPropagation(); onToggleSelect?.(printer.id); }}
+        >
+          {isSelected ? (
+            <CheckSquare className="w-5 h-5 text-bambu-green" />
+          ) : (
+            <Square className="w-5 h-5 text-bambu-gray" />
+          )}
+        </div>
+      )}
       {/* Drop zone overlay */}
       {/* Drop zone overlay */}
       {(isDraggingFile || isDropUploading) && (
       {(isDraggingFile || isDropUploading) && (
         <div
         <div
@@ -5903,6 +5925,102 @@ export function PrintersPage() {
     },
     },
   });
   });
 
 
+  // Bulk selection state
+  const [selectedPrinterIds, setSelectedPrinterIds] = useState<Set<number>>(new Set());
+  const [isSelectionMode, setIsSelectionMode] = useState(false);
+  const [bulkConfirmAction, setBulkConfirmAction] = useState<'stop' | 'pause' | 'clearPlate' | null>(null);
+  const [bulkActionPending, setBulkActionPending] = useState(false);
+  const selectionMode = isSelectionMode || selectedPrinterIds.size > 0;
+
+  const toggleSelect = useCallback((id: number) => {
+    setSelectedPrinterIds(prev => {
+      const next = new Set(prev);
+      if (next.has(id)) next.delete(id);
+      else next.add(id);
+      return next;
+    });
+  }, []);
+
+  const clearSelection = useCallback(() => {
+    setSelectedPrinterIds(new Set());
+    setIsSelectionMode(false);
+  }, []);
+
+  // Escape key exits selection mode
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && selectionMode) {
+        clearSelection();
+      }
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [selectionMode, clearSelection]);
+
+  const executeBulkAction = useCallback(async (action: 'stop' | 'pause' | 'resume' | 'clearPlate' | 'clearHMS') => {
+    setBulkActionPending(true);
+    const ids = Array.from(selectedPrinterIds);
+
+    // Filter to only applicable printers based on cached state
+    const applicableIds = ids.filter(id => {
+      const status = queryClient.getQueryData<{ connected: boolean; state: string | null; hms_errors?: HMSError[] }>(['printerStatus', id]);
+      if (!status?.connected) return false;
+      switch (action) {
+        case 'stop': return status.state === 'RUNNING' || status.state === 'PAUSE';
+        case 'pause': return status.state === 'RUNNING';
+        case 'resume': return status.state === 'PAUSE';
+        case 'clearPlate': return (status.state === 'FINISH' || status.state === 'FAILED') && !(status as { plate_cleared?: boolean }).plate_cleared;
+        case 'clearHMS': return status.hms_errors && filterKnownHMSErrors(status.hms_errors).length > 0;
+        default: return false;
+      }
+    });
+
+    if (applicableIds.length === 0) {
+      showToast(t('printers.bulk.noneApplicable'), 'error');
+      setBulkActionPending(false);
+      setBulkConfirmAction(null);
+      return;
+    }
+
+    const apiCall = {
+      stop: api.stopPrint,
+      pause: api.pausePrint,
+      resume: api.resumePrint,
+      clearPlate: api.clearPlate,
+      clearHMS: api.clearHMSErrors,
+    }[action];
+
+    const results = await Promise.allSettled(
+      applicableIds.map(id => apiCall(id))
+    );
+
+    const succeeded = results.filter(r => r.status === 'fulfilled').length;
+    const failed = results.filter(r => r.status === 'rejected').length;
+
+    if (failed === 0) {
+      showToast(t('printers.bulk.success', { action: t(`printers.bulk.actions.${action}`), count: succeeded }));
+    } else {
+      showToast(t('printers.bulk.partial', { succeeded, failed }), 'error');
+    }
+
+    // Invalidate status queries for affected printers
+    applicableIds.forEach(id => {
+      queryClient.invalidateQueries({ queryKey: ['printerStatus', id] });
+    });
+
+    setBulkActionPending(false);
+    setBulkConfirmAction(null);
+  }, [selectedPrinterIds, queryClient, showToast, t]);
+
+  const handleBulkAction = useCallback((action: 'stop' | 'pause' | 'resume' | 'clearPlate' | 'clearHMS') => {
+    // Actions that need confirmation
+    if (action === 'stop' || action === 'pause' || action === 'clearPlate') {
+      setBulkConfirmAction(action);
+    } else {
+      executeBulkAction(action);
+    }
+  }, [executeBulkAction]);
+
   const toggleHideDisconnected = () => {
   const toggleHideDisconnected = () => {
     const newValue = !hideDisconnected;
     const newValue = !hideDisconnected;
     setHideDisconnected(newValue);
     setHideDisconnected(newValue);
@@ -5982,6 +6100,40 @@ export function PrintersPage() {
     return sorted;
     return sorted;
   }, [printers, sortBy, sortAsc, queryClient]);
   }, [printers, sortBy, sortAsc, queryClient]);
 
 
+  const selectAll = useCallback(() => {
+    setSelectedPrinterIds(new Set(sortedPrinters.map(p => p.id)));
+    setIsSelectionMode(true);
+  }, [sortedPrinters]);
+
+  const selectByState = useCallback((state: PrinterState) => {
+    setSelectedPrinterIds(prev => {
+      const next = new Set(prev);
+      sortedPrinters.forEach(p => {
+        const status = queryClient.getQueryData<{ connected: boolean; state: string | null; hms_errors?: HMSError[] }>(['printerStatus', p.id]);
+        if (!status) return;
+        switch (state) {
+          case 'printing': if (status.connected && status.state === 'RUNNING') next.add(p.id); break;
+          case 'paused': if (status.connected && status.state === 'PAUSE') next.add(p.id); break;
+          case 'finished': if (status.connected && status.state === 'FINISH') next.add(p.id); break;
+          case 'idle': if (status.connected && status.state !== 'RUNNING' && status.state !== 'PAUSE' && status.state !== 'FINISH' && status.state !== 'FAILED') next.add(p.id); break;
+          case 'error': if (status.connected && (status.state === 'FAILED' || (status.hms_errors && filterKnownHMSErrors(status.hms_errors).length > 0))) next.add(p.id); break;
+          case 'offline': if (!status.connected) next.add(p.id); break;
+        }
+      });
+      return next;
+    });
+    setIsSelectionMode(true);
+  }, [sortedPrinters, queryClient]);
+
+  const selectByLocation = useCallback((location: string) => {
+    setSelectedPrinterIds(prev => {
+      const next = new Set(prev);
+      sortedPrinters.filter(p => (p.location || '') === location).forEach(p => next.add(p.id));
+      return next;
+    });
+    setIsSelectionMode(true);
+  }, [sortedPrinters]);
+
   // Group printers by location when sorted by location
   // Group printers by location when sorted by location
   const groupedPrinters = useMemo(() => {
   const groupedPrinters = useMemo(() => {
     if (sortBy !== 'location') return null;
     if (sortBy !== 'location') return null;
@@ -6057,6 +6209,23 @@ export function PrintersPage() {
             })}
             })}
           </div>
           </div>
 
 
+          {/* Bulk select toggle */}
+          <button
+            onClick={() => {
+              if (selectionMode) clearSelection();
+              else setIsSelectionMode(true);
+            }}
+            className={`p-1.5 rounded-lg transition-colors ${
+              selectionMode
+                ? 'bg-bambu-green text-white'
+                : 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
+            }`}
+            title={t('printers.bulk.select')}
+            disabled={!hasPermission('printers:control')}
+          >
+            <CheckSquare className="w-4 h-4" />
+          </button>
+
           <div className="w-px h-6 bg-bambu-dark-tertiary" />
           <div className="w-px h-6 bg-bambu-dark-tertiary" />
 
 
           <label className="flex items-center gap-2 text-sm text-bambu-gray cursor-pointer">
           <label className="flex items-center gap-2 text-sm text-bambu-gray cursor-pointer">
@@ -6148,6 +6317,14 @@ export function PrintersPage() {
                 <span className="w-2 h-2 rounded-full bg-bambu-green" />
                 <span className="w-2 h-2 rounded-full bg-bambu-green" />
                 {location}
                 {location}
                 <span className="text-sm font-normal text-bambu-gray">({locationPrinters.length})</span>
                 <span className="text-sm font-normal text-bambu-gray">({locationPrinters.length})</span>
+                {selectionMode && (
+                  <button
+                    onClick={() => selectByLocation(location === 'Ungrouped' ? '' : location)}
+                    className="text-xs text-bambu-green hover:text-bambu-green-light transition-colors ml-1"
+                  >
+                    {t('printers.bulk.selectAll')}
+                  </button>
+                )}
               </h2>
               </h2>
               <div className={`grid gap-4 ${cardSize >= 3 ? 'gap-6' : ''} ${getGridClasses()}`}>
               <div className={`grid gap-4 ${cardSize >= 3 ? 'gap-6' : ''} ${getGridClasses()}`}>
                 {locationPrinters.map((printer) => (
                 {locationPrinters.map((printer) => (
@@ -6176,6 +6353,9 @@ export function PrintersPage() {
                     onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
                     onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
                     checkPrinterFirmware={settings?.check_printer_firmware !== false}
                     checkPrinterFirmware={settings?.check_printer_firmware !== false}
                     dryingPresets={effectiveDryingPresets}
                     dryingPresets={effectiveDryingPresets}
+                    selectionMode={selectionMode}
+                    isSelected={selectedPrinterIds.has(printer.id)}
+                    onToggleSelect={toggleSelect}
                   />
                   />
                 ))}
                 ))}
               </div>
               </div>
@@ -6211,6 +6391,9 @@ export function PrintersPage() {
               onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
               onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
               checkPrinterFirmware={settings?.check_printer_firmware !== false}
               checkPrinterFirmware={settings?.check_printer_firmware !== false}
               dryingPresets={effectiveDryingPresets}
               dryingPresets={effectiveDryingPresets}
+              selectionMode={selectionMode}
+              isSelected={selectedPrinterIds.has(printer.id)}
+              onToggleSelect={toggleSelect}
             />
             />
           ))}
           ))}
         </div>
         </div>
@@ -6224,6 +6407,53 @@ export function PrintersPage() {
         />
         />
       )}
       )}
 
 
+      {/* Bulk selection toolbar */}
+      {selectionMode && printers && (
+        <BulkPrinterToolbar
+          selectedIds={selectedPrinterIds}
+          printers={printers}
+          onClose={clearSelection}
+          onSelectAll={selectAll}
+          onSelectByLocation={selectByLocation}
+          onSelectByState={selectByState}
+          onAction={handleBulkAction}
+          actionPending={bulkActionPending}
+        />
+      )}
+
+      {/* Bulk action confirmation modals */}
+      {bulkConfirmAction === 'stop' && (
+        <ConfirmModal
+          title={t('printers.bulk.confirm.stopTitle', { count: selectedPrinterIds.size })}
+          message={t('printers.bulk.confirm.stopMessage', { count: selectedPrinterIds.size })}
+          confirmText={t('printers.bulk.confirm.stopButton')}
+          variant="danger"
+          isLoading={bulkActionPending}
+          onConfirm={() => executeBulkAction('stop')}
+          onCancel={() => setBulkConfirmAction(null)}
+        />
+      )}
+      {bulkConfirmAction === 'pause' && (
+        <ConfirmModal
+          title={t('printers.bulk.confirm.pauseTitle', { count: selectedPrinterIds.size })}
+          message={t('printers.bulk.confirm.pauseMessage', { count: selectedPrinterIds.size })}
+          confirmText={t('printers.bulk.confirm.pauseButton')}
+          isLoading={bulkActionPending}
+          onConfirm={() => executeBulkAction('pause')}
+          onCancel={() => setBulkConfirmAction(null)}
+        />
+      )}
+      {bulkConfirmAction === 'clearPlate' && (
+        <ConfirmModal
+          title={t('printers.bulk.confirm.clearPlateTitle', { count: selectedPrinterIds.size })}
+          message={t('printers.bulk.confirm.clearPlateMessage', { count: selectedPrinterIds.size })}
+          confirmText={t('printers.bulk.confirm.clearPlateButton')}
+          isLoading={bulkActionPending}
+          onConfirm={() => executeBulkAction('clearPlate')}
+          onCancel={() => setBulkConfirmAction(null)}
+        />
+      )}
+
       {/* Embedded Camera Viewers - multiple viewers can be open simultaneously */}
       {/* Embedded Camera Viewers - multiple viewers can be open simultaneously */}
       {Array.from(embeddedCameraPrinters.values()).map((camera, index) => (
       {Array.from(embeddedCameraPrinters.values()).map((camera, index) => (
         <EmbeddedCameraViewer
         <EmbeddedCameraViewer

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-D7sc-Plx.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Dk9OaHEO.js"></script>
+    <script type="module" crossorigin src="/assets/index-D7sc-Plx.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-B4zcncds.css">
     <link rel="stylesheet" crossorigin href="/assets/index-B4zcncds.css">
   </head>
   </head>
   <body>
   <body>

Some files were not shown because too many files changed in this diff