import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { compareFwVersions } from '../utils/firmwareVersion'; import { formatPrintName } from '../utils/printName'; import { computePopoverPosition } from '../utils/popoverPosition'; import { resolveDryingPresetKey, type DryingPreset } from '../utils/dryingPresets'; import { isExternalSpoolHidden, setExternalSpoolHidden as persistExternalSpoolHidden, } from '../utils/printerCardPrefs'; import { BED_TEMP_DEFAULTS, CHAMBER_TEMP_DEFAULTS, FAN_SPEED_DEFAULTS, NOZZLE_TEMP_DEFAULTS, buildPresetOptions, parsePresetTriple, } from '../utils/temperatureFanPresets'; // AMS drying popover dimensions — w-[240px] on the popover, estimated height // covers header + filament select + temp slider + duration + rotate-tray // toggle + buttons. Over-estimating is fine (flip-above kicks in slightly // earlier); under-estimating leaves the popover clipped off the bottom (the // original bug at #1447). const DRYING_POPOVER_WIDTH = 240; const DRYING_POPOVER_ESTIMATED_HEIGHT = 320; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { useTheme } from '../contexts/ThemeContext'; import { useAuth } from '../contexts/AuthContext'; import { Plus, Link, Unlink, Signal, Clock, MoreVertical, Trash2, RefreshCw, RotateCw, Box, HardDrive, AlertTriangle, AlertCircle, Terminal, Power, Zap, Wrench, ChevronDown, Eye, EyeOff, Filter, Pencil, ArrowLeft, ArrowRight, ArrowUp, ArrowDown, Layers, Video, Search, Loader2, Square, Pause, Play, X, Fan, Wind, AirVent, Download, ScanSearch, CheckCircle, CheckSquare, XCircle, User, Home, Printer as PrinterIcon, Info, Cable, Flame, Repeat, Snowflake, Gauge, DoorOpen, DoorClosed, Move, LogIn, LogOut, MoreHorizontal, SlidersHorizontal, Stethoscope, ScanEye, LineChart as LineChartIcon, LayoutGrid, MonitorPlay, ExternalLink, } from 'lucide-react'; // Aliased: lucide-react already exports a `Link` icon into this module. import { Link as RouterLink, useNavigate } from 'react-router-dom'; import { api, discoveryApi, firmwareApi, withStreamToken, ApiError } from '../api/client'; import { formatDateOnly, formatETA, formatDuration, parseUTCDate } from '../utils/date'; import type { Printer, PrinterCreate, PrinterStatus, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo, SpoolAssignment, HMSError, InventorySpool, SmartPlug, PrinterDiagnosticResult } from '../api/client'; 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 { CameraWall } from '../components/CameraWall'; import { MQTTDebugModal } from '../components/MQTTDebugModal'; import { HMSErrorModal, filterKnownHMSErrors } from '../components/HMSErrorModal'; import { AiDetectionModal } from '../components/AiDetectionModal'; import { PrinterQueueWidget } from '../components/PrinterQueueWidget'; import { PrinterHASensorRow } from '../components/PrinterHASensorRow'; import { AMSHistoryModal } from '../components/AMSHistoryModal'; import { AmsBackupModal } from '../components/AmsBackupModal'; import { HeaterHistoryModal } from '../components/HeaterHistoryModal'; import type { HeaterSensorKind } from '../api/client'; import { FilamentHoverCard, EmptySlotHoverCard } from '../components/FilamentHoverCard'; import { LinkSpoolModal } from '../components/LinkSpoolModal'; import { AssignSpoolModal } from '../components/AssignSpoolModal'; import { ConfigureAmsSlotModal } from '../components/ConfigureAmsSlotModal'; import { useToast } from '../contexts/ToastContext'; import { ChamberLight } from '../components/icons/ChamberLight'; import { PlateClearedIcon } from '../components/icons/PlateClearedIcon'; import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModal'; import { FileUploadModal } from '../components/FileUploadModal'; import { PrintModal } from '../components/PrintModal'; import { PrinterInfoModal } from '../components/PrinterInfoModal'; import { getAmsLabel, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, isBambuLabSpool, resolveSlotNozzleDiameter } from '../utils/amsHelpers'; import { MAX_CHAMBER_TEMP_C, getPrinterImage, getWifiStrength, filterCompatibleQueueItems } from '../utils/printer'; import { FilamentSlotCircle } from '../components/FilamentSlotCircle'; import { Collapsible } from '../components/Collapsible'; import { ConnectionDiagnosticModal, DiagnosticChecklist } from '../components/ConnectionDiagnostic'; import { getColorName, parseFilamentColor, isLightColor } from '../utils/colors'; // The status filter's options, and the only values it may hold. One list so a // saved filter cannot be validated against a set the dropdown has since moved // on from (#2833). Labels stay literal so they remain greppable. const STATUS_FILTER_OPTIONS = [ { value: 'all', labelKey: 'printers.filter.allStatuses' }, { value: 'printing', labelKey: 'printers.status.printing' }, { value: 'paused', labelKey: 'printers.status.paused' }, { value: 'idle', labelKey: 'printers.status.idle' }, { value: 'finished', labelKey: 'printers.status.finished' }, { value: 'error', labelKey: 'printers.status.error' }, { value: 'offline', labelKey: 'printers.status.offline' }, ] as const; function isKnownStatusFilter(value: string | null): boolean { return STATUS_FILTER_OPTIONS.some(option => option.value === value); } export interface SpoolmanSlotAssignmentRow { printer_id: number; ams_id: number; tray_id: number; spoolman_spool_id: number; } // Color names resolve via getColorName() which reads the backend color_catalog // (loaded once by ColorCatalogProvider). No hardcoded tables here — see #857. // Format K value with 3 decimal places, default to 0.020 if null function formatKValue(k: number | null | undefined): string { const value = k ?? 0.020; return value.toFixed(3); } // Nozzle side indicators (Bambu Lab style - square badge with L/R) function NozzleBadge({ side }: { side: 'L' | 'R' }) { const { mode } = useTheme(); // Light mode: #e7f5e9 (light green), Dark mode: #1a4d2e (dark green) const bgColor = mode === 'dark' ? '#1a4d2e' : '#e7f5e9'; return ( {side} ); } // Expand nozzle type codes to material names // Handles full text ("hardened_steel"), 2-char codes ("HS"/"HH"), and 4-char codes ("HS01") // Material mapping: 00=stainless steel, 01=hardened steel, 05=tungsten carbide function nozzleTypeName(type: string, t: (key: string) => string): string { if (!type) return ''; // Full text names (from main nozzle info) if (type.includes('hardened')) return t('printers.nozzleHardenedSteel'); if (type.includes('stainless')) return t('printers.nozzleStainlessSteel'); if (type.includes('tungsten')) return t('printers.nozzleTungstenCarbide'); // 4-char codes (e.g. "HS01"): last 2 digits = material if (type.length >= 4) { const material = type.slice(2, 4); if (material === '00') return t('printers.nozzleStainlessSteel'); if (material === '01') return t('printers.nozzleHardenedSteel'); if (material === '05') return t('printers.nozzleTungstenCarbide'); } // 2-digit numeric codes if (type === '00') return t('printers.nozzleStainlessSteel'); if (type === '01') return t('printers.nozzleHardenedSteel'); if (type === '05') return t('printers.nozzleTungstenCarbide'); // 2-char alpha codes: H prefix = hardened steel if (type.startsWith('H')) return t('printers.nozzleHardenedSteel'); return type; } // Parse flow type from nozzle type code // HH = high flow, HS = standard/normal function nozzleFlowName(type: string, t: (key: string) => string): string { if (!type) return ''; if (type.startsWith('HH')) return t('printers.nozzleHighFlow'); if (type.startsWith('HS')) return t('printers.nozzleStandardFlow'); return ''; } // Per-slot hover card for nozzle rack // activeStatus: when true, show "Active" instead of "Mounted"/"Docked" (for hotend nozzles) function NozzleSlotHoverCard({ slot, index, activeStatus, filamentName, children }: { slot: import('../api/client').NozzleRackSlot; index: number; activeStatus?: boolean; filamentName?: string; children: React.ReactNode; }) { const { t } = useTranslation(); const [isVisible, setIsVisible] = useState(false); const [position, setPosition] = useState<'top' | 'bottom'>('top'); const triggerRef = useRef(null); const cardRef = useRef(null); const timeoutRef = useRef | null>(null); const isEmpty = !slot.nozzle_diameter && !slot.nozzle_type; const isMounted = slot.stat === 1; useEffect(() => { if (isVisible && triggerRef.current && cardRef.current) { const triggerRect = triggerRef.current.getBoundingClientRect(); const cardHeight = cardRef.current.offsetHeight; const headerHeight = 56; const spaceAbove = triggerRect.top - headerHeight; const spaceBelow = window.innerHeight - triggerRect.bottom; if (spaceAbove < cardHeight + 12 && spaceBelow > spaceAbove) { setPosition('bottom'); } else { setPosition('top'); } } }, [isVisible]); const handleMouseEnter = () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout(() => setIsVisible(true), 80); }; const handleMouseLeave = () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout(() => setIsVisible(false), 100); }; useEffect(() => { return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); }; }, []); const filamentCss = parseFilamentColor(slot.filament_color); const typeFull = nozzleTypeName(slot.nozzle_type, t); const flowFull = nozzleFlowName(slot.nozzle_type, t); return (
{children} {isVisible && (
{isEmpty ? (
Slot {index + 1} — Empty
) : (
{/* Diameter */}
{t('printers.nozzleDiameter')} {slot.nozzle_diameter} mm
{/* Type */} {typeFull && (
{t('printers.nozzleType')} {typeFull}
)} {/* Flow (hide if empty) */} {flowFull && (
{t('printers.nozzleFlow')} {flowFull}
)} {/* Status badge */}
{t('printers.nozzleStatus')} {activeStatus ? t('printers.nozzleActive') : isMounted ? t('printers.nozzleMounted') : t('printers.nozzleDocked')}
{/* Wear (hide if null) */} {slot.wear != null && (
{t('printers.nozzleWear')} {slot.wear}%
)} {/* Max Temp (hide if 0) */} {slot.max_temp > 0 && (
{t('printers.nozzleMaxTemp')} {slot.max_temp}°C
)} {/* Serial (hide if empty) */} {slot.serial_number && (
{t('printers.nozzleSerial')} {slot.serial_number}
)} {/* Filament: material type + color swatch (hide if no color) */} {(filamentCss || slot.filament_type) && (
{t('printers.nozzleFilament')}
{filamentCss && (
)} {filamentName || slot.filament_type || slot.filament_id || ''}
)}
)}
{/* Arrow pointer */}
)}
); } // Dual-nozzle hover card showing L and R nozzle details side by side function DualNozzleHoverCard({ leftSlot, rightSlot, activeNozzle, filamentInfo, children }: { leftSlot?: import('../api/client').NozzleRackSlot; rightSlot?: import('../api/client').NozzleRackSlot; activeNozzle: 'L' | 'R'; filamentInfo?: Record; children: React.ReactNode; }) { const { t } = useTranslation(); const [isVisible, setIsVisible] = useState(false); const [position, setPosition] = useState<'top' | 'bottom'>('top'); const triggerRef = useRef(null); const cardRef = useRef(null); const timeoutRef = useRef | null>(null); useEffect(() => { if (isVisible && triggerRef.current && cardRef.current) { const triggerRect = triggerRef.current.getBoundingClientRect(); const cardHeight = cardRef.current.offsetHeight; const headerHeight = 56; const spaceAbove = triggerRect.top - headerHeight; const spaceBelow = window.innerHeight - triggerRect.bottom; if (spaceAbove < cardHeight + 12 && spaceBelow > spaceAbove) { setPosition('bottom'); } else { setPosition('top'); } } }, [isVisible]); const handleMouseEnter = () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout(() => setIsVisible(true), 80); }; const handleMouseLeave = () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout(() => setIsVisible(false), 100); }; useEffect(() => { return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); }; }, []); if (!leftSlot && !rightSlot) return <>{children}; const renderColumn = (slot: import('../api/client').NozzleRackSlot, side: 'L' | 'R') => { const isActive = activeNozzle === side; const typeFull = nozzleTypeName(slot.nozzle_type, t); const flowFull = nozzleFlowName(slot.nozzle_type, t); const filamentCss = parseFilamentColor(slot.filament_color); const filamentName = slot.filament_id ? filamentInfo?.[slot.filament_id]?.name : undefined; return (
{side === 'L' ? t('common.left') : t('common.right')}
{slot.nozzle_diameter && (
{t('printers.nozzleDiameter')} {slot.nozzle_diameter} mm
)} {typeFull && (
{t('printers.nozzleType')} {typeFull}
)} {flowFull && (
{t('printers.nozzleFlow')} {flowFull}
)}
{t('printers.nozzleStatus')} {isActive ? t('printers.nozzleActive') : t('printers.nozzleIdle')}
{slot.wear != null && (
{t('printers.nozzleWear')} {slot.wear}%
)} {/* Serial and max temp only available on the right (removable) nozzle */} {side === 'R' && slot.max_temp > 0 && (
{t('printers.nozzleMaxTemp')} {slot.max_temp}°C
)} {side === 'R' && slot.serial_number && (
{t('printers.nozzleSerial')} {slot.serial_number}
)} {(filamentCss || slot.filament_type || slot.filament_id) && (
{t('printers.nozzleFilament')}
{filamentCss && (
)} {filamentName || slot.filament_type || slot.filament_id || ''}
)}
); }; return (
{children} {isVisible && (
{leftSlot && renderColumn(leftSlot, 'L')} {leftSlot && rightSlot &&
} {rightSlot && renderColumn(rightSlot, 'R')}
{/* Arrow pointer */}
)}
); } // H2C Nozzle Rack Card — compact single row showing 6-position tool-changer dock function NozzleRackCard({ slots, filamentInfo }: { slots: import('../api/client').NozzleRackSlot[]; filamentInfo?: Record }) { const { t } = useTranslation(); // Rack nozzles only (IDs >= 2) — excludes L/R hotend nozzles (IDs 0, 1). // H2C rack slot IDs are fixed at 16..21. When a nozzle is picked up into the // hotend the firmware omits that rack ID entirely, so we must map by the fixed // base — computing it from min(present IDs) shifts everything left when slot 16 // is the one currently mounted (#943). const rackNozzles = slots.filter(s => s.id >= 2); const RACK_SIZE = 6; const RACK_BASE_ID = 16; const rackSlots: (import('../api/client').NozzleRackSlot)[] = Array.from( { length: RACK_SIZE }, (_, i) => rackNozzles.find(s => s.id === RACK_BASE_ID + i) ?? { id: -(i + 1), nozzle_type: '', nozzle_diameter: '', wear: null, stat: null, max_temp: 0, serial_number: '', filament_color: '', filament_id: '', filament_type: '', }, ); return ( // Sized to its contents rather than growing: the six chips are a fixed // width at any one card size, so extra width would be dead space taken // from the temperature cards beside it — which are flex-1 with a 0 basis // and wrap their values ("220° / 220°") as soon as they lose it. Shrink // stays enabled so the card still gives way on a narrow printer card // instead of overflowing.

{t('printers.nozzleRack')}

{rackSlots.map((slot, i) => { const isEmpty = !slot.nozzle_diameter && !slot.nozzle_type; const filamentBg = !isEmpty ? parseFilamentColor(slot.filament_color) : null; const lightBg = filamentBg ? isLightColor(slot.filament_color) : false; return ( = 0 ? slot.id : `empty-${i}`} slot={slot} index={i} filamentName={slot.filament_id ? filamentInfo?.[slot.filament_id]?.name : undefined}>
{isEmpty ? '—' : (slot.nozzle_diameter || '?')}
{/* Physical rack position, so "swap the nozzle in slot 4" can be acted on without counting chips. Sits below the chip rather than inside it: the chip is barely wider than its own diameter text and already carries that over a filament-coloured background. */} {i + 1}
); })}
); } // Water drop SVG - empty outline (Bambu Lab style from bambu-humidity) function WaterDropEmpty({ className }: { className?: string }) { return ( ); } // Water drop SVG - half filled with blue water (Bambu Lab style from bambu-humidity) function WaterDropHalf({ className }: { className?: string }) { return ( ); } // Water drop SVG - fully filled with blue water (Bambu Lab style from bambu-humidity) function WaterDropFull({ className }: { className?: string }) { return ( ); } // Thermometer SVG - empty outline function ThermometerEmpty({ className }: { className?: string }) { return ( ); } // Thermometer SVG - half filled (gold - same as humidity fair) function ThermometerHalf({ className }: { className?: string }) { return ( ); } // Thermometer SVG - fully filled (red - same as humidity bad) function ThermometerFull({ className }: { className?: string }) { return ( ); } // Nozzle icon - schematic hot-end view (filament body + heater block + tip). // Added for visual parity with the thermometer icons on the dual-nozzle card // that previously had no icon at all (#1115, design by @m4rtini2). function NozzleIcon({ className }: { className?: string }) { return ( ); } // Heater thermometer icon - filled when heating, outline when off interface HeaterThermometerProps { className?: string; color: string; // The color class (e.g., "text-orange-400") isHeating: boolean; } function HeaterThermometer({ className, color, isHeating }: HeaterThermometerProps) { // Extract the actual color from Tailwind class for SVG fill const colorMap: Record = { 'text-orange-400': '#fb923c', 'text-blue-400': '#60a5fa', 'text-green-400': '#4ade80', }; const fillColor = colorMap[color] || '#888'; // Glow style when heating const glowStyle = isHeating ? { filter: `drop-shadow(0 0 4px ${fillColor}) drop-shadow(0 0 8px ${fillColor})`, } : {}; if (isHeating) { // Filled thermometer with glow - heater is ON return ( ); } // Empty thermometer - heater is OFF return ( ); } // AMS Filament Backup tri-state indicator + toggle. // state=true → ON, click to disable // state=false → OFF, click opens modal // state=null → unknown/unsupported (e.g. A1 family), click disabled interface AmsBackupBadgeProps { state: boolean | null; onClick: () => void; } function AmsBackupBadge({ state, onClick }: AmsBackupBadgeProps) { const { t } = useTranslation(); const known = state !== null; let className = 'flex items-center justify-center w-[18px] h-[18px] rounded text-[length:var(--pc-t10,10px)] transition-colors '; let title: string; if (state === true) { className += known ? 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400 hover:bg-blue-500/30 cursor-pointer' : 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400 cursor-default'; title = t('printers.amsBackup.titleOn'); } else if (state === false) { className += known ? 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark/80 cursor-pointer' : 'bg-bambu-dark text-bambu-gray cursor-default'; title = t('printers.amsBackup.titleOff'); } else { className += 'bg-bambu-dark text-bambu-gray/50 cursor-default'; title = t('printers.amsBackup.titleUnknown'); } return ( ); } // Hide/show the external spool in the filament row (#1782). Sized and shaped // like AmsBackupBadge so the two sit together in the section header, but // pinned to the right-hand end of the rule: this is a view preference for the // row, not a property of the printer. interface ExternalSpoolToggleProps { hidden: boolean; onClick: () => void; } function ExternalSpoolToggle({ hidden, onClick }: ExternalSpoolToggleProps) { const { t } = useTranslation(); const title = hidden ? t('printers.externalSpool.show') : t('printers.externalSpool.hide'); return ( ); } // Humidity indicator with water drop that fills based on level (Bambu Lab style) // Reference: https://github.com/theicedmango/bambu-humidity interface HumidityIndicatorProps { humidity: number | string; goodThreshold?: number; // <= this is green fairThreshold?: number; // <= this is orange, > is red onClick?: () => void; compact?: boolean; // Smaller version for grid layout } function HumidityIndicator({ humidity, goodThreshold = 40, fairThreshold = 60, onClick, compact }: HumidityIndicatorProps) { const humidityValue = typeof humidity === 'string' ? parseInt(humidity, 10) : humidity; const good = typeof goodThreshold === 'number' ? goodThreshold : 40; const fair = typeof fairThreshold === 'number' ? fairThreshold : 60; // Status thresholds (configurable via settings) // Good: ≤goodThreshold (green #22a352), Fair: ≤fairThreshold (gold #d4a017), Bad: >fairThreshold (red #c62828) let textColor: string; let statusText: string; if (isNaN(humidityValue)) { textColor = '#C3C2C1'; statusText = 'Unknown'; } else if (humidityValue <= good) { textColor = '#22a352'; // Green - Good statusText = 'Good'; } else if (humidityValue <= fair) { textColor = '#d4a017'; // Gold - Fair statusText = 'Fair'; } else { textColor = '#c62828'; // Red - Bad statusText = 'Bad'; } // Fill level based on status: Good=Empty (dry), Fair=Half, Bad=Full (wet) let DropComponent: React.FC<{ className?: string }>; if (isNaN(humidityValue)) { DropComponent = WaterDropEmpty; } else if (humidityValue <= good) { DropComponent = WaterDropEmpty; // Good - empty drop (dry) } else if (humidityValue <= fair) { DropComponent = WaterDropHalf; // Fair - half filled } else { DropComponent = WaterDropFull; // Bad - full (too humid) } return ( ); } // Temperature indicator with dynamic icon and coloring interface TemperatureIndicatorProps { temp: number; goodThreshold?: number; // <= this is blue fairThreshold?: number; // <= this is orange, > is red onClick?: () => void; compact?: boolean; // Smaller version for grid layout } function TemperatureIndicator({ temp, goodThreshold = 28, fairThreshold = 35, onClick, compact }: TemperatureIndicatorProps) { // Ensure thresholds are numbers const good = typeof goodThreshold === 'number' ? goodThreshold : 28; const fair = typeof fairThreshold === 'number' ? fairThreshold : 35; let textColor: string; let statusText: string; let ThermoComponent: React.FC<{ className?: string }>; if (temp <= good) { textColor = '#22a352'; // Green - good (same as humidity) statusText = 'Good'; ThermoComponent = ThermometerEmpty; } else if (temp <= fair) { textColor = '#d4a017'; // Gold - fair (same as humidity) statusText = 'Fair'; ThermoComponent = ThermometerHalf; } else { textColor = '#c62828'; // Red - bad (same as humidity) statusText = 'Bad'; ThermoComponent = ThermometerFull; } return ( ); } /** Classify an empty AMS slot for UI rendering (#1322 follow-up). * * "physical" — firmware positively confirmed no spool (state 9 or 10). The * bambu_mqtt handler now promotes tray_exist_bits=0 slots to state=9, so * every empty-by-bitmask slot lands here regardless of firmware payload * shape. * * "reset" — tray_type is missing/empty but firmware hasn't confirmed * emptiness (state is null, 3, or any non-9/10 value). Typically a slot * the user cleared with "Reset Slot" where a physical spool may still be * loaded but unassigned. * * Returns null when the slot is loaded (tray_type is present). */ function getEmptySlotKind(tray: { tray_type?: string | null; state?: number | null; exists?: boolean | null } | null | undefined): 'physical' | 'reset' | null { if (tray?.tray_type) return null; // tray_exist_bits is firmware's authoritative presence signal: a non-RFID // spool the firmware can't identify is physically present (exists === true) // but carries no tray_type, so it must read as "?" (loaded, unconfigured), // never "Empty" (#2527). BambuStudio draws it the same way. Only fall back to // the state=9/10 heuristic when the bitmask was unavailable (exists == null). if (tray?.exists === true) return 'reset'; if (tray?.exists === false) return 'physical'; return (tray?.state === 9 || tray?.state === 10) ? 'physical' : 'reset'; } // How long to wait for an AMS to report a live drying cycle after the printer // acked the start command (#2533). Firmware moves to DryStatus 1 (Checking) // within a couple of seconds; 30s covers the slowest observed push cadence // without leaving the user waiting on a verdict. const DRY_START_CONFIRM_MS = 30_000; export function CoverImage({ url, printName, className = 'w-20 h-20', radiusClass = 'rounded-lg', }: { url: string | null; printName?: string; className?: string; radiusClass?: string; }) { const { t } = useTranslation(); const [loaded, setLoaded] = useState(false); const [error, setError] = useState(false); const [showOverlay, setShowOverlay] = useState(false); const imgRef = useRef(null); // Cache-bust the image URL when the print name changes so the browser // fetches the new cover instead of serving the stale cached image. const cacheBustedUrl = useMemo(() => { if (!url) return null; const sep = url.includes('?') ? '&' : '?'; return withStreamToken(`${url}${sep}v=${encodeURIComponent(printName || Date.now().toString())}`); }, [url, printName]); // Re-evaluate load state when the image URL changes, and ask the element // whether it is already showing something rather than assuming it is not. // // `onLoad` used to be the only thing that could set `loaded`, and this // effect reset it to false unconditionally. That is right when the URL // really changes, but it also runs on mount — and the two are not the same // situation (#2826). The URL is cache-busted on the print *name*, which is // constant for the duration of a print, so navigating away from the // printers page and back re-mounts with a byte-identical src that the // browser serves from its in-memory cache. The `load` event for a cache hit // and React's passive-effect flush are both plain tasks with no ordering // between them, so when `load` won, this effect ran afterwards and undid // it: `loaded` stayed false, the img stayed `hidden`, and the placeholder // sat there until a full reload. Nothing recovered it, because the URL // never changes again during the print and so this effect never re-runs. // // Being a race, it reproduced every time for #2826's reporter and not at // all here, which is also why the network panel showed no request: there // was no request to show. // // Asking `complete && naturalWidth > 0` settles it without needing to know // which of the two ran first. It is also correct for the case the reporter // proposed (a `load` that fires before React's handler is live), so the // fix stands whichever mechanism is really at work. useEffect(() => { setError(false); const el = imgRef.current; setLoaded(Boolean(el?.complete && el.naturalWidth > 0)); }, [cacheBustedUrl]); return ( <>
cacheBustedUrl && loaded && setShowOverlay(true)} > {cacheBustedUrl && !error ? ( <> {t('printers.printPreview')} setLoaded(true)} onError={() => setError(true)} /> {!loaded && } ) : ( )}
{/* Cover Image Overlay */} {showOverlay && cacheBustedUrl && (
setShowOverlay(false)} >
{t('printers.printPreview')} {printName && (

{printName}

)}
)} ); } interface PrinterMaintenanceInfo { due_count: number; warning_count: number; total_print_hours: number; } // Status summary bar component - uses queryClient to read cached statuses function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) { const { t } = useTranslation(); const queryClient = useQueryClient(); // Subscribe to query cache changes to re-render when status updates // Throttled to prevent rapid re-renders from causing tab crashes const [cacheTick, setCacheTick] = useState(0); useEffect(() => { let pending = false; const unsubscribe = queryClient.getQueryCache().subscribe(() => { if (!pending) { pending = true; requestAnimationFrame(() => { setCacheTick(t => t + 1); pending = false; }); } }); return () => unsubscribe(); }, [queryClient]); const { counts, nextFinish } = useMemo(() => { let printing = 0; let paused = 0; let finished = 0; let idle = 0; let offline = 0; let loading = 0; let error = 0; let nextPrinterName: string | null = null; let nextRemainingMin: number | null = null; let nextProgress: number = 0; printers?.forEach((printer) => { const status = queryClient.getQueryData<{ connected: boolean; state: string | null; remaining_time: number | null; progress: number | null; hms_errors?: HMSError[] }>(['printerStatus', printer.id]); if (status === undefined) { // Status not yet loaded - don't count as offline yet loading++; } else if (!status.connected) { offline++; } else { // Count printers with active HMS errors as problems const knownHmsCount = status.hms_errors ? filterKnownHMSErrors(status.hms_errors).length : 0; if (knownHmsCount > 0) { error++; } 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; } } break; case 'PAUSE': paused++; break; case 'FINISH': finished++; break; case 'FAILED': // FAILED is the printer's terminal gcode_state after a print stops — // including user cancellations, where there's no actual fault. Only // count it as a "problem" when an HMS error is also active; otherwise // it's just a print that ended unsuccessfully and the plate needs // clearing (same as FINISH from the operator's perspective). if (knownHmsCount > 0) { // Already counted above } else { finished++; } break; default: idle++; break; } } }); return { 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 }, [printers, queryClient, cacheTick]); 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 (
{badges.map(({ count, dot, label }) => count > 0 && (
{count} {label}
))} {nextFinish && ( <>
{t('printers.nextAvailable')}: {nextFinish.name}
{Math.round(nextFinish.progress)}% ({formatDuration(nextFinish.remainingMin * 60)})
)}
); } type SortOption = 'name' | 'status' | 'model' | 'location' | 'eta'; type ViewMode = 'expanded' | 'compact'; type ToolbarDropdownOption = { value: T; label: string; }; function ToolbarDropdown({ value, options, onChange, fullWidth = false, }: { value: T; options: ToolbarDropdownOption[]; onChange: (value: T) => void; fullWidth?: boolean; }) { const [isOpen, setIsOpen] = useState(false); const selectedOption = options.find(option => option.value === value) ?? options[0]; return (
{isOpen && ( <>
setIsOpen(false)} />
{options.map(option => ( ))}
)}
); } function ToolbarMenu({ label, icon, children, }: { label: string; icon: React.ReactNode; children: React.ReactNode; }) { const [isOpen, setIsOpen] = useState(false); return (
{isOpen && ( <>
setIsOpen(false)} />
{children}
)}
); } function IndicatorControlPopover({ title, options = [], unit, customMin, customMax, customStep = 1, widthClass = 'w-[240px]', popoverWidth = 240, popoverHeight = 280, isPending, onClose, onSubmit, children, }: { title: string; options?: Array<{ label: string; value: number }>; unit?: string; customMin?: number; customMax?: number; customStep?: number; widthClass?: string; popoverWidth?: number; popoverHeight?: number; isPending?: boolean; onClose: () => void; onSubmit?: (value: number) => void; children?: React.ReactNode; }) { const anchorRef = useRef(null); const [coords, setCoords] = useState<{ top: number; left: number } | null>(null); const [customValue, setCustomValue] = useState(''); // Anchor to the trigger (the popover's DOM parent before portaling) so we // can position via fixed coords. Portaling to document.body escapes // ancestor stacking contexts — sibling PrinterCard wrappers create their // own contexts and would otherwise cover the popover even at z-[60]. useLayoutEffect(() => { const trigger = anchorRef.current?.parentElement; if (!trigger) return; const measure = () => { const rect = trigger.getBoundingClientRect(); setCoords(computePopoverPosition({ triggerRect: rect, popoverWidth, estimatedHeight: popoverHeight, horizontalAlign: 'center', })); }; measure(); window.addEventListener('resize', measure); window.addEventListener('scroll', measure, true); return () => { window.removeEventListener('resize', measure); window.removeEventListener('scroll', measure, true); }; }, [popoverWidth, popoverHeight]); const showCustomInput = unit !== undefined; const submitCustom = () => { const value = Number(customValue); if (!Number.isFinite(value)) return; const bounded = Math.min(customMax ?? value, Math.max(customMin ?? value, value)); onSubmit?.(Math.round(bounded)); }; return ( <>