mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-20 17:26:33 +02:00
fix(queue): enforce sliced-model compatibility on cross-model dispatch (#2578)
A queue item's "Any <model>" button labeled itself from the file's slice metadata while the scheduler used the row's target_model, so an X1C-sliced item targeting H2D showed "Any X1C" above "assign to first idle H2D". The mismatch itself was created silently: sliced-for metadata loads async, and switching to model mode before it arrived pre-selected the alphabetically first model (H2D on a mixed farm), after which the model dropdown hid itself. Nothing validated compatibility, so the scheduler would hand X1C G-code to an H2D. Frontend: never default the target silently, keep the dropdown visible in model mode (incompatible models disabled), label from the actual target, warn on mismatch, block submit when incompatible. Backend: new GCODE_COMPAT_FAMILIES table (X1/X1C/X1E/P1P/P1S interchange; everything else exact-match; missing metadata never blocks). Queue create and update reject incompatible targets with 400; the scheduler holds back pre-existing mismatched rows with an actionable waiting_reason instead of dispatching them.
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { api, type PrinterStatus } from '../../api/client';
|
||||
import { getColorName } from '../../utils/colors';
|
||||
import { isGcodeCompatible } from '../../utils/printer';
|
||||
import {
|
||||
normalizeColorForCompare,
|
||||
colorsAreSimilar,
|
||||
@@ -409,10 +410,15 @@ export function PrinterSelector({
|
||||
onClick={() => {
|
||||
onAssignmentModeChange!('model');
|
||||
onMultiSelect([]);
|
||||
// Pre-select the sliced-for model if available, otherwise first model
|
||||
// Pre-select the sliced-for model when it has printers. NEVER
|
||||
// silently fall back to another model (#2578): uniqueModels is
|
||||
// alphabetically sorted, so the old `uniqueModels[0]` default
|
||||
// quietly targeted e.g. H2D for an X1C-sliced file whenever the
|
||||
// metadata hadn't loaded yet. Leave it unset and let the user
|
||||
// pick from the dropdown instead.
|
||||
const defaultModel = slicedForModel && uniqueModels.includes(slicedForModel)
|
||||
? slicedForModel
|
||||
: uniqueModels[0];
|
||||
: null;
|
||||
onTargetModelChange!(defaultModel);
|
||||
}}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
|
||||
@@ -422,7 +428,12 @@ export function PrinterSelector({
|
||||
}`}
|
||||
>
|
||||
<Users className="w-4 h-4" />
|
||||
<span className="text-sm">Any {slicedForModel || 'Model'}</span>
|
||||
{/* In model mode the label must reflect the ACTUAL scheduler
|
||||
target, not the slice metadata — an edited queue item can
|
||||
target a different model than the file was sliced for (#2578). */}
|
||||
<span className="text-sm">
|
||||
Any {(assignmentMode === 'model' ? targetModel : null) || slicedForModel || 'Model'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -430,29 +441,58 @@ export function PrinterSelector({
|
||||
{/* Model selection and location filter (when in model mode) */}
|
||||
{assignmentMode === 'model' && modelAssignmentAvailable && (
|
||||
<div className="space-y-3 mb-4">
|
||||
{/* Model selector — only show when sliced model is unknown */}
|
||||
{!slicedForModel && (
|
||||
<div>
|
||||
<label className="block text-xs text-bambu-gray mb-1">Target Model</label>
|
||||
<select
|
||||
value={targetModel || ''}
|
||||
onChange={(e) => {
|
||||
onTargetModelChange!(e.target.value || null);
|
||||
// Clear location when model changes
|
||||
if (onTargetLocationChange) {
|
||||
onTargetLocationChange(null);
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
|
||||
>
|
||||
<option value="">Select a model...</option>
|
||||
{uniqueModels.map((model) => (
|
||||
<option key={model} value={model}>
|
||||
{/* Model selector — always visible in model mode so a wrong target
|
||||
on an existing queue item can be seen and fixed (#2578).
|
||||
Incompatible models are disabled: G-code sliced for one model
|
||||
must only go to that model or its interchange family. */}
|
||||
<div>
|
||||
<label className="block text-xs text-bambu-gray mb-1">
|
||||
Target Model
|
||||
{slicedForModel && <span className="ml-2 text-bambu-gray/70">(sliced for {slicedForModel})</span>}
|
||||
</label>
|
||||
<select
|
||||
value={targetModel || ''}
|
||||
onChange={(e) => {
|
||||
onTargetModelChange!(e.target.value || null);
|
||||
// Clear location when model changes
|
||||
if (onTargetLocationChange) {
|
||||
onTargetLocationChange(null);
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
|
||||
>
|
||||
<option value="">Select a model...</option>
|
||||
{uniqueModels.map((model) => {
|
||||
const compatible = isGcodeCompatible(slicedForModel, model);
|
||||
return (
|
||||
<option key={model} value={model} disabled={!compatible}>
|
||||
{model}
|
||||
{!compatible ? ` — incompatible with ${slicedForModel} G-code` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Cross-model state on an existing row: same file/target mismatch
|
||||
the specific-printer path already warns about (#2578). */}
|
||||
{slicedForModel && targetModel && targetModel !== slicedForModel && (
|
||||
isGcodeCompatible(slicedForModel, targetModel) ? (
|
||||
<div className="p-3 bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-300 dark:border-yellow-500/30 rounded-lg flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 flex-shrink-0" />
|
||||
<span className="text-sm text-yellow-700 dark:text-yellow-400">
|
||||
File was sliced for {slicedForModel}, but will be dispatched to a {targetModel} printer
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30 rounded-lg flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0" />
|
||||
<span className="text-sm text-red-700 dark:text-red-400">
|
||||
File was sliced for {slicedForModel} and cannot be dispatched to {targetModel} printers —
|
||||
select a compatible model
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Location filter (only show when target model is selected and locations exist) */}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '../../hooks/useFilamentMapping';
|
||||
import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
|
||||
import { getColorName } from '../../utils/colors';
|
||||
import { isGcodeCompatible } from '../../utils/printer';
|
||||
import { getCurrencySymbol } from '../../utils/currency';
|
||||
import { getBedTypeInfo } from '../../utils/bedType';
|
||||
import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';
|
||||
@@ -576,6 +577,17 @@ export function PrintModal({
|
||||
}
|
||||
}, [targetModel, selectedPlate, prevTargetModel, prevPlateForOverrides, mode]);
|
||||
|
||||
// The sliced-for metadata loads async. If the user switched to model mode
|
||||
// before it arrived, the target is still empty (we never silently default
|
||||
// to another model, #2578) — fill it with the sliced-for model once known,
|
||||
// provided an active printer of that model exists.
|
||||
useEffect(() => {
|
||||
if (assignmentMode !== 'model' || targetModel || !slicedForModel) return;
|
||||
if (printers?.some((p) => p.is_active && p.model === slicedForModel)) {
|
||||
setTargetModel(slicedForModel);
|
||||
}
|
||||
}, [assignmentMode, targetModel, slicedForModel, printers]);
|
||||
|
||||
// Auto-expand per-printer mapping when setting is enabled and multiple printers selected
|
||||
// Only applies once per printer on initial selection, not when user unchecks
|
||||
useEffect(() => {
|
||||
@@ -781,6 +793,12 @@ export function PrintModal({
|
||||
showToast('Please select a target printer model', 'error');
|
||||
return;
|
||||
}
|
||||
// Cross-model safety gate (#2578) — mirrors the backend's 400 so the user
|
||||
// gets inline feedback instead of a failed request.
|
||||
if (assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) {
|
||||
showToast(`File was sliced for ${slicedForModel} and cannot be dispatched to ${targetModel} printers`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
// Calculate total API calls: plates × printers (or 1 for model-based)
|
||||
@@ -1070,6 +1088,8 @@ export function PrintModal({
|
||||
// Need valid printer/model selection
|
||||
if (assignmentMode === 'printer' && selectedPrinters.length === 0) return false;
|
||||
if (assignmentMode === 'model' && !targetModel) return false;
|
||||
// Cross-model mismatch cannot be queued (#2578)
|
||||
if (assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) return false;
|
||||
|
||||
// For multi-plate files, need at least one plate selected
|
||||
if (isMultiPlate && selectedPlates.size === 0) return false;
|
||||
@@ -1085,6 +1105,7 @@ export function PrintModal({
|
||||
selectedPrinters.length,
|
||||
assignmentMode,
|
||||
targetModel,
|
||||
slicedForModel,
|
||||
isMultiPlate,
|
||||
selectedPlates.size,
|
||||
isPending,
|
||||
|
||||
Reference in New Issue
Block a user