mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
VP queue-mode multi-plate Send All
==========================================
BambuStudio / OrcaSlicer "Send All" of a multi-plate project uploads ONE
3MF containing every plate (one FTP STOR, single filename) — slice_info.config
inside the file lists N <plate> blocks with their own index metadata and
their own Metadata/plate_N.gcode payload. Pre-#1733 the VP queue path
called _extract_plate_id which returned only the FIRST plate index, and
_add_to_print_queue built exactly one PrintQueueItem from it. Plates 2..N
silently dropped on the floor. From the user's perspective: Send All of a
3-plate project produced 1 queue item, indistinguishable from a regular
single-plate Send, with no log line to explain the discrepancy.
The wire was confirmed against the live H2D-1 Proxy VP: the same file
ships whether the user clicked Send or Send All; the only intent signal
is the count of <plate> blocks inside slice_info.config.
Fix: replaced _extract_plate_id (-> int | None) with _extract_plate_ids
(-> list[int]). The list contains every <plate> block's index in order;
falls back to [1] when slice_info.config is missing / unparseable so the
single-plate case is preserved. _add_to_print_queue now loops over the
list and creates one PrintQueueItem per plate, with:
- plate-specific position = MAX(position) + iteration_number, so the
items inherit consecutive positions and the slicer's plate order
becomes the queue execution order.
- per-plate required_filament_types / filament_overrides via
extract_filament_requirements(file_path, plate_id) — the plate-aware
filter shipped with #1697 — so the scheduler's per-printer "Any X"
matching dispatches each plate onto a printer with the right
colours loaded for THAT plate, not for plate 1's filament set.
- shared archive_id across all plates (one upload = one archive row).
- the VP's auto_dispatch + manual_start posture inherited unchanged.
Net behaviour: single-plate Send hits the loop once → exactly today's
result (one queue item, plate_id from the slicer, one archive). Multi-
plate Send All of a 3-plate file → 3 queue items, plate_id 1/2/3,
consecutive positions, all referencing the same backing archive.
Archive delete cascades to queue rows
=============================================
Previously the soft-delete path (the default the trash-can button uses)
called _cancel_pending_queue_items which only flipped queue rows with
status='pending' to status='cancelled' while leaving every other status
alone AND leaving every row in the DB. The Send All multi-plate work
above made this much more visible: deleting an archive backed by N
queue items now had to clean up N rows, and what users saw instead was
N "cancelled" rows lingering in the queue history.
Backend:
- Replaced _cancel_pending_queue_items with _delete_related_queue_items
(db, archive_id) -> int. DELETEs every queue row where
archive_id = X regardless of status. Matches what the hard-delete
path already did via the ON DELETE CASCADE FK on
print_queue.archive_id — both paths now produce the same end state.
- Print history lives in PrintLogEntry (FK ON DELETE SET NULL) and is
untouched; Quick Stats / accuracy bands are preserved across both
delete paths.
- 409 guard on archives.py::delete_archive when any related queue
item is currently status='printing'. Both soft and hard delete are
gated; deleting the archive while a print is live would strip the
dispatcher's metadata trail (filament / plate / ams_mapping) out
from under the running print.
- New GET /archives/{id}/delete-impact endpoint returns
{related_queue_items: N, currently_printing: M}. Cheap, single
endpoint, deliberately NOT folded into the archive list response
so the much larger list endpoint isn't forced to run the same
query per row.
Frontend:
- ArchivesPage delete-confirm modal queries the new endpoint when the
modal opens (useQuery with enabled: showDeleteConfirm) and renders
an amber "N queue items linked to this archive will also be removed"
line when total > 0 AND printing = 0, OR a red "Cannot delete —
M queue items are currently printing" line when printing > 0
(confirm button disabled in that case so the user can't bonk the
409 on submit).
- ConfirmModal gained an optional confirmDisabled?: boolean prop —
isLoading was the only disable knob before; this adds the external-
precondition path.
- 2 new i18n keys (deleteQueueItemsWarning, deleteBlockedByPrinting)
translated across all 11 locales per feedback_translate_dont_fallback —
no English fallbacks.
No DB migration — the CASCADE FK was already in place; only the helper's
semantics changed.
130 lines
4.3 KiB
TypeScript
130 lines
4.3 KiB
TypeScript
import { useEffect, type ReactNode } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { AlertTriangle, Loader2 } from 'lucide-react';
|
|
import { Card, CardContent } from './Card';
|
|
import { Button } from './Button';
|
|
|
|
interface ConfirmModalProps {
|
|
title: string;
|
|
message: string;
|
|
confirmText?: string;
|
|
cancelText?: string;
|
|
cancelVariant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
|
cardClassName?: string;
|
|
// Tailwind z-index utility applied to the fixed overlay. Defaults to
|
|
// ``z-50``. Use a higher value (e.g. ``z-[110]``) when this confirm
|
|
// dialog is rendered from inside another modal that uses ``z-[100]`` —
|
|
// without it the confirm dialog sits behind its parent (#1336 follow-up).
|
|
overlayZIndex?: string;
|
|
variant?: 'danger' | 'warning' | 'default';
|
|
isLoading?: boolean;
|
|
loadingText?: string;
|
|
// Disable the confirm button without a loading spinner. Used when an
|
|
// external precondition forbids the action (e.g. #1734 — a related queue
|
|
// item is mid-print, so the archive delete must be blocked at the UI
|
|
// layer too even though the backend will 409 anyway).
|
|
confirmDisabled?: boolean;
|
|
// Optional extra content rendered between the message and the buttons —
|
|
// used for opt-in checkboxes (e.g. the "Also remove from statistics"
|
|
// toggle in the archive delete confirmation, #1343).
|
|
children?: ReactNode;
|
|
onConfirm: () => void;
|
|
onCancel: () => void;
|
|
}
|
|
|
|
export function ConfirmModal({
|
|
title,
|
|
message,
|
|
confirmText,
|
|
cancelText,
|
|
cancelVariant,
|
|
cardClassName,
|
|
overlayZIndex,
|
|
variant = 'default',
|
|
isLoading = false,
|
|
loadingText,
|
|
confirmDisabled = false,
|
|
children,
|
|
onConfirm,
|
|
onCancel,
|
|
}: ConfirmModalProps) {
|
|
const { t } = useTranslation();
|
|
const resolvedConfirmText = confirmText ?? t('common.confirm');
|
|
const resolvedCancelText = cancelText ?? t('common.cancel');
|
|
const resolvedLoadingText = loadingText ?? t('common.loading');
|
|
// Close on Escape key (but not while loading)
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape' && !isLoading) onCancel();
|
|
};
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [onCancel, isLoading]);
|
|
|
|
const variantStyles = {
|
|
danger: {
|
|
icon: 'text-red-400',
|
|
button: 'bg-red-500 hover:bg-red-600',
|
|
},
|
|
warning: {
|
|
icon: 'text-yellow-400',
|
|
button: 'bg-yellow-500 hover:bg-yellow-600 text-black',
|
|
},
|
|
default: {
|
|
icon: 'text-bambu-green',
|
|
button: 'bg-bambu-green hover:bg-bambu-green-dark',
|
|
},
|
|
};
|
|
|
|
const styles = variantStyles[variant];
|
|
|
|
return (
|
|
<div
|
|
className={`fixed inset-0 bg-black/50 flex items-center justify-center p-4 ${overlayZIndex ?? 'z-50'}`}
|
|
onClick={isLoading ? undefined : onCancel}
|
|
>
|
|
<Card
|
|
className={`w-full max-w-md ${cardClassName ?? ''}`}
|
|
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
|
>
|
|
<CardContent className="p-6">
|
|
<div className="flex items-start gap-4">
|
|
<div className={`p-2 rounded-full bg-bambu-dark ${styles.icon}`}>
|
|
<AlertTriangle className="w-6 h-6" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<h3 className="text-lg font-semibold text-white mb-2">{title}</h3>
|
|
<p className="text-bambu-gray text-sm whitespace-pre-line">{message}</p>
|
|
{children && <div className="mt-4">{children}</div>}
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-3 mt-6">
|
|
<Button
|
|
variant={cancelVariant ?? 'secondary'}
|
|
onClick={onCancel}
|
|
className="flex-1"
|
|
disabled={isLoading}
|
|
>
|
|
{resolvedCancelText}
|
|
</Button>
|
|
<Button
|
|
onClick={onConfirm}
|
|
className={`flex-1 ${styles.button}`}
|
|
disabled={isLoading || confirmDisabled}
|
|
>
|
|
{isLoading ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
{resolvedLoadingText}
|
|
</>
|
|
) : (
|
|
resolvedConfirmText
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|