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:
maziggy
2026-07-17 06:51:51 +02:00
parent a6e7d671f2
commit e97413edc7
12 changed files with 729 additions and 174 deletions
@@ -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,