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.
This commit is contained in:
maziggy
2026-03-31 13:24:43 +02:00
parent ec0ae162e8
commit 3c887f5166
14 changed files with 1300 additions and 506 deletions
@@ -3,7 +3,7 @@
*/
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 { PrintersPage } from '../../pages/PrintersPage';
import { http, HttpResponse } from 'msw';
@@ -385,4 +385,95 @@ describe('PrintersPage', () => {
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();
});
});
});
});
@@ -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
View File
@@ -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.',
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: {
title: 'Drucker entdecken',
+29
View File
@@ -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.',
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: {
title: 'Discover Printers',
+29
View File
@@ -383,6 +383,35 @@ export default {
powerOffWarning: 'ATTENTION : "{{name}}" imprime ! L\'éteindre maintenant peut endommager l\'imprimante.',
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: {
title: 'Découvrir les imprimantes',
+29
View File
@@ -383,6 +383,35 @@ export default {
powerOffWarning: 'AVVISO: "{{name}}" sta stampando! Sei sicuro di spegnere? Questo interromperà la stampa e potrebbe danneggiare la stampante.',
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: {
title: 'Trova Stampanti',
+29
View File
@@ -382,6 +382,35 @@ export default {
powerOffWarning: '警告: 「{{name}}」は現在印刷中です!電源をオフにしますか?印刷が中断され、プリンターが損傷する可能性があります。',
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: {
title: 'プリンター',
+29
View File
@@ -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.',
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: {
title: 'Descobrir Impressoras',
+29
View File
@@ -383,6 +383,35 @@ export default {
powerOffWarning: '警告:"{{name}}"正在打印中!确定要关闭电源吗?这将中断打印并可能损坏打印机。',
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: {
title: '发现打印机',
+271 -41
View File
@@ -39,6 +39,7 @@ import {
Download,
ScanSearch,
CheckCircle,
CheckSquare,
XCircle,
User,
Home,
@@ -56,6 +57,7 @@ import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdate
import { Card, CardContent } from '../components/Card';
import { Button } from '../components/Button';
import { ConfirmModal } from '../components/ConfirmModal';
import { BulkPrinterToolbar, type PrinterState } from '../components/BulkPrinterToolbar';
import { FileManagerModal } from '../components/FileManagerModal';
import { EmbeddedCameraViewer } from '../components/EmbeddedCameraViewer';
import { MQTTDebugModal } from '../components/MQTTDebugModal';
@@ -1120,10 +1122,12 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
const { counts, nextFinish } = useMemo(() => {
let printing = 0;
let paused = 0;
let finished = 0;
let idle = 0;
let offline = 0;
let loading = 0;
let problem = 0;
let error = 0;
let nextPrinterName: string | null = null;
let nextRemainingMin: number | null = null;
let nextProgress: number = 0;
@@ -1138,25 +1142,37 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
} else {
// Count printers with HMS errors
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 {
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,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -1164,38 +1180,25 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
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 (
<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" />
{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-white font-medium">{counts.printing}</span> {t('printers.status.printing').toLowerCase()}
<span className="text-white font-medium">{count}</span> {label}
</span>
</div>
)}
{counts.offline > 0 && (
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full bg-gray-400" />
<span className="text-bambu-gray">
<span className="text-white font-medium">{counts.offline}</span> {t('printers.status.offline').toLowerCase()}
</span>
</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 && (
<>
<div className="w-px h-4 bg-bambu-dark-tertiary" />
@@ -1517,6 +1520,9 @@ function PrinterCard({
onOpenEmbeddedCamera,
checkPrinterFirmware = true,
dryingPresets = DRYING_PRESETS,
selectionMode = false,
isSelected = false,
onToggleSelect,
}: {
printer: Printer;
hideIfDisconnected?: boolean;
@@ -1542,6 +1548,9 @@ function PrinterCard({
onOpenEmbeddedCamera?: (printerId: number, printerName: string) => void;
checkPrinterFirmware?: boolean;
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 queryClient = useQueryClient();
@@ -2340,12 +2349,25 @@ function PrinterCard({
return (
<Card
className="relative"
className={`relative ${isSelected ? 'ring-2 ring-bambu-green' : ''} ${selectionMode ? 'cursor-pointer' : ''}`}
onDragEnter={handleCardDragEnter}
onDragOver={handleCardDragOver}
onDragLeave={handleCardDragLeave}
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 */}
{(isDraggingFile || isDropUploading) && (
<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 newValue = !hideDisconnected;
setHideDisconnected(newValue);
@@ -5982,6 +6100,40 @@ export function PrintersPage() {
return sorted;
}, [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
const groupedPrinters = useMemo(() => {
if (sortBy !== 'location') return null;
@@ -6057,6 +6209,23 @@ export function PrintersPage() {
})}
</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" />
<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" />
{location}
<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>
<div className={`grid gap-4 ${cardSize >= 3 ? 'gap-6' : ''} ${getGridClasses()}`}>
{locationPrinters.map((printer) => (
@@ -6176,6 +6353,9 @@ export function PrintersPage() {
onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
checkPrinterFirmware={settings?.check_printer_firmware !== false}
dryingPresets={effectiveDryingPresets}
selectionMode={selectionMode}
isSelected={selectedPrinterIds.has(printer.id)}
onToggleSelect={toggleSelect}
/>
))}
</div>
@@ -6211,6 +6391,9 @@ export function PrintersPage() {
onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
checkPrinterFirmware={settings?.check_printer_firmware !== false}
dryingPresets={effectiveDryingPresets}
selectionMode={selectionMode}
isSelected={selectedPrinterIds.has(printer.id)}
onToggleSelect={toggleSelect}
/>
))}
</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 */}
{Array.from(embeddedCameraPrinters.values()).map((camera, index) => (
<EmbeddedCameraViewer