import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query'; import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Pencil, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon, ScanEye, Cog, QrCode, Heart, Briefcase, Workflow, UploadCloud, MonitorPlay, Info } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { api } from '../api/client'; import { useAuth } from '../contexts/AuthContext'; import { formatDateOnly } from '../utils/date'; import { getCurrencySymbol, SUPPORTED_CURRENCIES } from '../utils/currency'; import { checkPasswordComplexity } from '../utils/password'; import { fleetAudience, sponsorHref } from '../utils/fleetAudience'; import { PRESET_CATEGORIES, parsePresetTriple } from '../utils/temperatureFanPresets'; import { CALIBRATION_MODES, CALIBRATION_MODE_ACTIVE, CALIBRATION_MODE_INACTIVE } from '../utils/calibrationMode'; import { inventoryLocationsQueryKey } from '../utils/inventoryQueries'; import { locationSensorReadingAlertStatus, locationSensorValueColorClass, useLocationSensorColorPrefs, } from '../utils/locationSensorDefaults'; import { describeHASensorReading, iconForHASensor } from '../utils/haSensorDisplay'; import { PreheatFilamentTargetsEditor } from '../components/PreheatFilamentTargetsEditor'; import type { Printer as PrinterInfo, APIKey, AppSettings, AppSettingsUpdate, PrinterHASensor, LocationHASensor, LocationHASensorReading, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse, CalibrationMode } from '../api/client'; import { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card'; import { SlicerPipelinesPanel } from '../components/SlicerPipelinesPanel'; import { CameraTokensSection } from './CameraTokensPage'; import { ConnectedAppsSection } from '../components/ConnectedAppsSection'; import { StreamOverlayBuilder } from '../components/StreamOverlayBuilder'; import { Collapsible } from '../components/Collapsible'; import { CopyButton } from '../components/CopyButton'; import { Button } from '../components/Button'; import { SmartPlugCard } from '../components/SmartPlugCard'; import { AddSmartPlugModal } from '../components/AddSmartPlugModal'; import { HASensorModal } from '../components/HASensorModal'; import { LocationHASensorModal } from '../components/LocationHASensorModal'; import { LocationSensorOptionsModal } from '../components/LocationSensorOptionsModal'; import { NotificationProviderCard } from '../components/NotificationProviderCard'; import { AddNotificationModal } from '../components/AddNotificationModal'; import { NotificationTemplateEditor } from '../components/NotificationTemplateEditor'; import { NotificationLogViewer } from '../components/NotificationLogViewer'; import { ConfirmModal } from '../components/ConfirmModal'; import { ApiKeyQRCodeModal } from '../components/ApiKeyQRCodeModal'; import { CreateUserAdvancedAuthModal } from '../components/CreateUserAdvancedAuthModal'; import { LdapUserPicker } from '../components/LdapUserPicker'; import { SpoolmanSettings } from '../components/SpoolmanSettings'; import { SpoolCatalogSettings } from '../components/SpoolCatalogSettings'; import { ColorCatalogSettings } from '../components/ColorCatalogSettings'; import { ExternalLinksSettings } from '../components/ExternalLinksSettings'; import { VirtualPrinterList } from '../components/VirtualPrinterList'; import { SpoolBuddySettings } from '../components/SpoolBuddySettings'; import { GitHubBackupSettings } from '../components/GitHubBackupSettings'; import { FailureDetectionSettings } from '../components/FailureDetectionSettings'; import { EmailSettings } from '../components/EmailSettings'; import { LDAPSettings } from '../components/LDAPSettings'; import { PrinterAccessSettings } from '../components/PrinterAccessSettings'; import { TwoFactorSettings } from '../components/TwoFactorSettings'; import { OIDCProviderSettings } from '../components/OIDCProviderSettings'; import { SecurityStatusCard } from '../components/SecurityStatusCard'; import { APIBrowser } from '../components/APIBrowser'; import { virtualPrinterApi, spoolbuddyApi } from '../api/client'; import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout'; import { availableLanguages } from '../i18n'; import { useToast } from '../contexts/ToastContext'; import { useTheme, type ThemeStyle, type DarkBackground, type LightBackground, type ThemeAccent } from '../contexts/ThemeContext'; import { useState, useEffect, useRef, useCallback, useMemo, type ReactNode } from 'react'; import { Gauge, Link2, Palette, type LucideIcon } from 'lucide-react'; import { registerSettingsSearch, getSettingsSearchEntries } from '../lib/settingsSearch'; import type { UsersSubTab } from '../lib/settingsSearch'; import { availableEngines, hasEngineChoice, resolveEngine, type SliceEngineId } from '../lib/sliceEngines'; import { NumberInput } from '../components/NumberInput'; import { PrinterSearchPicker } from '../components/PrinterSearchPicker'; const validTabs = ['general', 'plugs', 'sensors', 'notifications', 'queue', 'filament', 'camera', 'network', 'apikeys', 'virtual-printer', 'spoolbuddy', 'failure-detection', 'users', 'backup'] as const; type TabType = typeof validTabs[number]; interface SettingsTabEntry { tab: TabType; icon: LucideIcon; label: string; extra?: ReactNode } // Cross-tab search registrations for cards rendered inline in this file. // Adding a new settings card? Register it here (or, if the card lives in its // own component file, call registerSettingsSearch at that file's module scope). registerSettingsSearch({ labelKey: 'settings.general', tab: 'general', keywords: 'language date time format printer model printers cards', anchor: 'card-general' }); registerSettingsSearch({ labelKey: 'settings.appearance', tab: 'general', keywords: 'theme dark light mode colors', anchor: 'card-appearance' }); registerSettingsSearch({ labelKey: 'settings.archiveSettings', tab: 'general', keywords: 'archive auto save thumbnails captures', anchor: 'card-archive' }); registerSettingsSearch({ labelKey: 'settings.camera', tab: 'camera', keywords: 'camera external video stream rtsp mjpeg usb snapshot rotation chamber light led lamp', anchor: 'card-camera' }); registerSettingsSearch({ labelKey: 'settings.costTracking', tab: 'general', keywords: 'currency filament cost energy kwh price', anchor: 'card-cost' }); registerSettingsSearch({ labelKey: 'settings.fileManager', tab: 'general', keywords: 'file manager archive mode disk warning storage', anchor: 'card-filemanager' }); registerSettingsSearch({ labelKey: 'settings.updates', tab: 'general', keywords: 'updates version firmware beta check', anchor: 'card-updates' }); registerSettingsSearch({ labelKey: 'settings.dataManagement', tab: 'general', keywords: 'data reset clear logs notifications preferences', anchor: 'card-data' }); registerSettingsSearch({ labelKey: 'settings.smartPlugs', tab: 'plugs', keywords: 'smart plug energy power automation tapo kasa tplink shelly', anchor: 'card-plugs' }); registerSettingsSearch({ labelKey: 'haSensors.sectionTitle', tab: 'sensors', keywords: 'home assistant sensor printer temperature humidity alert notify block print', anchor: 'card-ha-sensors' }); registerSettingsSearch({ labelKey: 'locationHaSensors.sectionTitle', tab: 'sensors', keywords: 'home assistant sensor location storage box temperature humidity battery alert notify', anchor: 'card-location-sensors' }); registerSettingsSearch({ labelKey: 'settings.providers', tab: 'notifications', keywords: 'telegram discord email notification providers webhook', anchor: 'card-providers' }); registerSettingsSearch({ labelKey: 'settings.messageTemplates', tab: 'notifications', keywords: 'message templates notification text edit', anchor: 'card-templates' }); registerSettingsSearch({ labelKey: 'settings.defaultPrintOptions', labelFallback: 'Default Print Options', tab: 'queue', keywords: 'print bed leveling flow calibration vibration first layer timelapse', anchor: 'card-print-options' }); registerSettingsSearch({ labelKey: 'settings.tempFanPresetsTitle', labelFallback: 'Temperature & Fan Presets', tab: 'queue', keywords: 'temperature fan presets nozzle bed chamber quick buttons popover', anchor: 'card-temp-fan-presets' }); registerSettingsSearch({ labelKey: 'settings.staggeredStart', labelFallback: 'Staggered Start', tab: 'queue', keywords: 'staggered batch delay start queue group', anchor: 'card-staggered' }); registerSettingsSearch({ labelKey: 'settings.plateClear', labelFallback: 'Plate-Clear Confirmation', tab: 'queue', keywords: 'plate clear confirm auto queue', anchor: 'card-plate' }); registerSettingsSearch({ labelKey: 'settings.concurrentUploadsTitle', labelFallback: 'Concurrent Uploads', tab: 'queue', keywords: 'concurrent parallel upload transfer ftp queue slow farm simultaneous', anchor: 'card-concurrent-uploads' }); registerSettingsSearch({ labelKey: 'settings.gcodeInjection', labelFallback: 'G-code Injection', tab: 'queue', keywords: 'gcode injection start end autoprint farmloop swapmod autoclear printflow', anchor: 'card-gcode' }); registerSettingsSearch({ labelKey: 'settings.slicerCard', labelFallback: 'Slicer', tab: 'queue', keywords: 'slicer orcaslicer bambustudio orca bambu api sidecar url docker preferred', anchor: 'card-slicer' }); registerSettingsSearch({ labelKey: 'settings.queueDrying', tab: 'queue', keywords: 'drying presets temperature time humidity ams', anchor: 'card-drying' }); registerSettingsSearch({ labelKey: 'settings.filamentChecks', tab: 'filament', keywords: 'filament check warning runout remaining', anchor: 'card-filamentchecks' }); registerSettingsSearch({ labelKey: 'settings.printModal', tab: 'filament', keywords: 'print modal custom mapping', anchor: 'card-printmodal' }); registerSettingsSearch({ labelKey: 'settings.amsDisplayThresholds', tab: 'filament', keywords: 'ams humidity temperature threshold history retention', anchor: 'card-amsthresholds' }); registerSettingsSearch({ labelKey: 'settings.externalUrl', tab: 'network', keywords: 'external url reverse proxy public notification link', anchor: 'card-externalurl' }); registerSettingsSearch({ labelKey: 'settings.ftpRetry', tab: 'network', keywords: 'ftp retry upload retries backoff', anchor: 'card-ftpretry' }); registerSettingsSearch({ labelKey: 'settings.homeAssistant', tab: 'network', keywords: 'home assistant ha hass mqtt integration', anchor: 'card-ha' }); registerSettingsSearch({ labelKey: 'settings.mqttPublishing', tab: 'network', keywords: 'mqtt publish broker topic', anchor: 'card-mqtt' }); registerSettingsSearch({ labelKey: 'settings.prometheusMetrics', tab: 'network', keywords: 'prometheus metrics grafana monitoring bearer token', anchor: 'card-prometheus' }); registerSettingsSearch({ labelKey: 'settings.createNewApiKey', tab: 'apikeys', keywords: 'api key create permission scope', anchor: 'card-createapi' }); registerSettingsSearch({ labelKey: 'settings.webhookEndpoints', tab: 'apikeys', keywords: 'webhook endpoint post http', anchor: 'card-webhooks' }); registerSettingsSearch({ labelKey: 'settings.apiBrowser', tab: 'apikeys', keywords: 'api browser endpoint documentation test', anchor: 'card-apibrowser' }); registerSettingsSearch({ labelKey: 'connectedApps.title', tab: 'apikeys', keywords: 'connected app sign in single sign-on sso oauth login orders', anchor: 'card-connected-apps' }); registerSettingsSearch({ labelKey: 'cameraTokens.title', tab: 'camera', keywords: 'camera token long-lived home assistant frigate kiosk stream cam wall', anchor: 'card-camera-tokens' }); registerSettingsSearch({ labelKey: 'streamOverlay.builder.title', labelFallback: 'Streaming Overlay', tab: 'camera', keywords: 'streaming overlay obs browser source stream url token', anchor: 'card-stream-overlay' }); registerSettingsSearch({ labelKey: 'settings.tabs.virtualPrinter', tab: 'virtual-printer', keywords: 'virtual printer proxy archive slicer bambustudio orcaslicer ip bind', anchor: 'card-vp' }); registerSettingsSearch({ labelKey: 'settings.tabs.spoolbuddy', tab: 'spoolbuddy', keywords: 'spoolbuddy device scale nfc rfid kiosk unregister', anchor: 'card-spoolbuddy' }); registerSettingsSearch({ labelKey: 'settings.currentUser', tab: 'users', subTab: 'users', keywords: 'current user profile password change', anchor: 'card-currentuser' }); registerSettingsSearch({ labelKey: 'settings.users', tab: 'users', subTab: 'users', keywords: 'users accounts list', anchor: 'card-users' }); registerSettingsSearch({ labelKey: 'settings.groups', tab: 'users', subTab: 'users', keywords: 'groups roles permissions administrators operators viewers', anchor: 'card-groups' }); registerSettingsSearch({ labelKey: 'printerAccess.tab', tab: 'users', subTab: 'printer-access', keywords: 'printer access groups teams locations limit restrict scope who can see', anchor: 'card-printer-access' }); registerSettingsSearch({ labelKey: 'settings.sessionPolicy.title', labelFallback: 'Session Policy', tab: 'users', subTab: 'users', keywords: 'session timeout expiry logout remember me jwt token lifetime', anchor: 'card-session-policy' }); registerSettingsSearch({ labelKey: 'settings.email.smtpSettings', labelFallback: 'SMTP Configuration', tab: 'users', subTab: 'email', keywords: 'smtp email send server port password auth starttls ssl', anchor: 'card-smtp' }); registerSettingsSearch({ labelKey: 'settings.ldap.title', labelFallback: 'LDAP Authentication', tab: 'users', subTab: 'ldap', keywords: 'ldap active directory ad authentication bind dn search base group mapping', anchor: 'card-ldap' }); registerSettingsSearch({ labelKey: 'settings.tabs.backup', tab: 'backup', keywords: 'backup github restore download cloud sync profiles archives', anchor: 'card-backup' }); // Sidebar (system pages and external links settings is rendered in the General tab) registerSettingsSearch({ labelKey: 'externalLinks.sidebarLayout', labelFallback: 'Sidebar', tab: 'general', keywords: 'sidebar layout links pages hide show external custom navigation url add', anchor: 'card-sidebar-links' }); // Filament tab — integrations registerSettingsSearch({ labelKey: 'settings.filamentTracking', tab: 'filament', keywords: 'spoolman filament tracking inventory sync remote integration', anchor: 'card-spoolman' }); registerSettingsSearch({ labelKey: 'settings.catalog.spoolCatalog', labelFallback: 'Spool Catalog', tab: 'filament', keywords: 'spool catalog entries brand material reset import export', anchor: 'card-spool-catalog' }); registerSettingsSearch({ labelKey: 'settings.colorCatalog.title', labelFallback: 'Color Catalog', tab: 'filament', keywords: 'color catalog hex swatch palette sync reset', anchor: 'card-color-catalog' }); // Failure detection sub-cards registerSettingsSearch({ labelKey: 'settings.tabs.failureDetection', labelFallback: 'Failure Detection', tab: 'failure-detection', keywords: 'failure detection ai ml obico spaghetti detect monitoring', anchor: 'card-fd-ml' }); registerSettingsSearch({ labelKey: 'failureDetection.perPrinterTitle', labelFallback: 'Per-Printer Settings', tab: 'failure-detection', keywords: 'failure detection per printer enable per-printer sensitivity', anchor: 'card-fd-perprinter' }); registerSettingsSearch({ labelKey: 'failureDetection.statusTitle', labelFallback: 'Detection Status', tab: 'failure-detection', keywords: 'failure detection status running connection', anchor: 'card-fd-status' }); registerSettingsSearch({ labelKey: 'failureDetection.historyTitle', labelFallback: 'Detection History', tab: 'failure-detection', keywords: 'failure detection history log events', anchor: 'card-fd-history' }); // Email auth sub-cards (subTab=email) registerSettingsSearch({ labelKey: 'settings.email.advancedAuth', labelFallback: 'Advanced Email Authentication', tab: 'users', subTab: 'email', keywords: 'email authentication advanced password reset self-service forgot', anchor: 'card-email-advanced-auth' }); registerSettingsSearch({ labelKey: 'settings.email.testConnection', labelFallback: 'Test SMTP Connection', tab: 'users', subTab: 'email', keywords: 'email smtp test connection send check', anchor: 'card-email-test' }); // Two-Factor sub-cards (subTab=twofa) registerSettingsSearch({ labelKey: 'settings.twoFa.totpTitle', labelFallback: 'Authenticator App (TOTP)', tab: 'users', subTab: 'twofa', keywords: 'two factor 2fa totp authenticator app google authy otp', anchor: 'card-2fa-totp' }); registerSettingsSearch({ labelKey: 'settings.twoFa.emailOtpTitle', labelFallback: 'Email One-Time Codes', tab: 'users', subTab: 'twofa', keywords: 'two factor 2fa email otp one time code', anchor: 'card-2fa-emailotp' }); registerSettingsSearch({ labelKey: 'settings.twoFa.linkedAccounts', labelFallback: 'Linked Accounts', tab: 'users', subTab: 'twofa', keywords: 'two factor 2fa linked accounts sso oidc provider google github', anchor: 'card-2fa-linked' }); // OIDC / SSO (subTab=oidc) registerSettingsSearch({ labelKey: 'settings.oidc.title', labelFallback: 'Single Sign-On (OIDC)', tab: 'users', subTab: 'oidc', keywords: 'sso oidc openid single sign-on pocketid authentik keycloak google okta azure provider', anchor: 'card-oidc' }); // LDAP server config card (complements existing card-ldap) registerSettingsSearch({ labelKey: 'settings.ldap.serverConfig', labelFallback: 'LDAP Server Configuration', tab: 'users', subTab: 'ldap', keywords: 'ldap server url bind dn user search base group filter tls', anchor: 'card-ldap-server' }); // Backup sub-cards registerSettingsSearch({ labelKey: 'backup.githubBackup', labelFallback: 'GitHub Backup', tab: 'backup', keywords: 'github backup cloud remote sync profiles token', anchor: 'card-backup-github' }); registerSettingsSearch({ labelKey: 'backup.history', labelFallback: 'Backup History', tab: 'backup', keywords: 'backup history log runs github commits', anchor: 'card-backup-history' }); registerSettingsSearch({ labelKey: 'backup.localBackup', labelFallback: 'Local Backup', tab: 'backup', keywords: 'local backup download zip manual export', anchor: 'card-backup-local' }); registerSettingsSearch({ labelKey: 'backup.scheduledBackup', labelFallback: 'Scheduled Backups', tab: 'backup', keywords: 'scheduled backup automatic hourly daily weekly retention local path', anchor: 'card-backup-scheduled' }); // Lowest humidity an AMS reports while its own dryer is running, measured on an // H2D/AMS 2 Pro that read 10-13% cold and 15-20% throughout every cycle (#2770). // A drying threshold below this can never be reached while the box is warm, so // auto-drying would end one cycle and immediately arm another. const HUMIDITY_DRYING_FLOOR = 20; const STORAGE_CATEGORY_COLORS: Record = { database: 'bg-blue-600', library_files: 'bg-green-500', library_thumbnails: 'bg-teal-500', library_other: 'bg-emerald-700', archive_timelapses: 'bg-red-500', archive_thumbnails: 'bg-amber-500', archive_files: 'bg-sky-500', virtual_printer_uploads: 'bg-purple-500', virtual_printer_upload_cache: 'bg-fuchsia-500', virtual_printer_certs: 'bg-violet-500', virtual_printer_other: 'bg-purple-700', downloads: 'bg-cyan-500', plate_calibration: 'bg-lime-500', logs: 'bg-orange-500', other_data: 'bg-yellow-500', }; const STORAGE_FALLBACK_COLORS = [ 'bg-blue-500', 'bg-green-500', 'bg-yellow-500', 'bg-red-500', 'bg-orange-500', 'bg-teal-500', 'bg-cyan-500', 'bg-purple-500', ]; const getStorageColor = (key: string, index: number) => STORAGE_CATEGORY_COLORS[key] || STORAGE_FALLBACK_COLORS[index % STORAGE_FALLBACK_COLORS.length]; const LOCATION_SENSOR_CATEGORY_ORDER: Record = { temperature: 0, humidity: 1, battery: 2, }; // Reads the live readings endpoint (reachable-aware) rather than the sensor // row's last_state, so a sensor Home Assistant has stopped reporting shows // "Unavailable" instead of silently keeping its last value on screen forever. function describeLocationSensorValue( reading: LocationHASensorReading | undefined, t: (key: string, opts?: Record) => string ): string | null { if (!reading) return null; return describeHASensorReading(reading, t, { decimals: 2 }); } // Chamber light for the camera (#1655), in the order the choice is shown. const CAMERA_LIGHT_MODES = ['off', 'all', 'selected'] as const; /** Closed-row hint for an external camera: its type and where it points. */ function externalCameraSummary(printer: PrinterInfo): string { const type = (printer.external_camera_type || 'mjpeg').toUpperCase(); const url = printer.external_camera_url || ''; if (!url) return type; let where = url; try { where = new URL(url).host || url; } catch { // A USB device path or an unfinished URL: show it as typed. } return `${type} · ${where}`; } export function SettingsPage() { const [cameraTokenRevision, setCameraTokenRevision] = useState(0); const queryClient = useQueryClient(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const { t, i18n } = useTranslation(); const { showToast } = useToast(); const { authEnabled, user, isAdmin, refreshAuth, hasPermission, loading: authLoading } = useAuth(); const { mode, resolvedMode, darkStyle, darkBackground, darkAccent, lightStyle, lightBackground, lightAccent, setMode, setDarkStyle, setDarkBackground, setDarkAccent, setLightStyle, setLightBackground, setLightAccent, progressInTitle, setProgressInTitle, } = useTheme(); const [localSettings, setLocalSettings] = useState(null); // Transient typed strings for the per-filament humidity threshold inputs // (#1605). Committed back to localSettings.ams_humidity_thresholds on blur // so intermediate values ("", "3", "5") are not eaten by the [5, 95] clamp // while the user is mid-typing. const [humidityDrafts, setHumidityDrafts] = useState>({}); // Same transient-draft treatment for the ambient-drying sustained-minutes // input: committed (clamped 5..240) on blur, so clearing the field or typing // an intermediate value ("", "2" on the way to "25") is not rewritten by the // clamp mid-keystroke. Empty/non-numeric on blur reverts to the saved value. const [sustainedDraft, setSustainedDraft] = useState(null); const [showPlugModal, setShowPlugModal] = useState(false); const [editingPlug, setEditingPlug] = useState(null); const [showHASensorModal, setShowHASensorModal] = useState(false); const [editingHASensor, setEditingHASensor] = useState(null); const [showLocationHASensorModal, setShowLocationHASensorModal] = useState(false); const [editingLocationHASensor, setEditingLocationHASensor] = useState(null); const [showLocationSensorOptionsModal, setShowLocationSensorOptionsModal] = useState(false); const { colorize: colorizeLocationSensorValues, aboveColor: locationSensorAboveColor, belowColor: locationSensorBelowColor, optimalColor: locationSensorOptimalColor, } = useLocationSensorColorPrefs(); const [deleteLocationSensorsTarget, setDeleteLocationSensorsTarget] = useState<{ locationName: string; sensorIds: number[]; } | null>(null); const [showNotificationModal, setShowNotificationModal] = useState(false); const [editingProvider, setEditingProvider] = useState(null); const [editingTemplate, setEditingTemplate] = useState(null); const [templateFilter, setTemplateFilter] = useState(''); const [settingsSearch, setSettingsSearch] = useState(''); const [showLogViewer, setShowLogViewer] = useState(false); const [showRebuildConfirm, setShowRebuildConfirm] = useState(false); const [isRebuildLoading, setIsRebuildLoading] = useState(false); const [defaultView, setDefaultViewState] = useState(getDefaultView()); // Initialize tab from URL params (handle legacy ?tab=email → users tab + email sub-tab) const tabParam = searchParams.get('tab'); const isLegacyEmailTab = tabParam === 'email'; // Camera tokens and the overlay builder used to live under API Keys; links // to them (bookmarks, the wiki, /camera-tokens) open the Camera tab instead. const canSeeApiKeysTab = hasPermission('api_keys:read') || hasPermission('settings:update'); const isLegacyCameraLink = tabParam === 'apikeys' && ['#card-camera-tokens', '#card-stream-overlay'].includes(window.location.hash); const initialTab = isLegacyEmailTab ? 'users' : isLegacyCameraLink ? 'camera' : (tabParam && validTabs.includes(tabParam as TabType) ? tabParam as TabType : 'general'); const [activeTab, setActiveTab] = useState(initialTab); // Only Printer access is deep-linked (?tab=users&sub=printer-access), from the printer card menu const [usersSubTab, setUsersSubTab] = useState( isLegacyEmailTab ? 'email' : tabParam === 'users' && searchParams.get('sub') === 'printer-access' ? 'printer-access' : 'users' ); const selectUsersSubTab = (sub: UsersSubTab) => { setUsersSubTab(sub); if (sub === 'printer-access') { searchParams.set('sub', 'printer-access'); } else { for (const key of ['sub', 'view', 'group', 'printer']) searchParams.delete(key); } setSearchParams(searchParams, { replace: true }); }; // A link to Printer access lands non-admins on the Users sub-tab instead const shownUsersSubTab: UsersSubTab = usersSubTab === 'printer-access' && !(authEnabled && isAdmin) ? 'users' : usersSubTab; // Workflow tab sub-tabs (#1425): 'dispatch' = current Workflow content, // 'pipelines' = Slicer Pipelines management. URL: ?tab=queue&sub=pipelines. const initialQueueSub: 'dispatch' | 'pipelines' = tabParam === 'queue' && searchParams.get('sub') === 'pipelines' ? 'pipelines' : 'dispatch'; const [queueSubTab, setQueueSubTab] = useState<'dispatch' | 'pipelines'>(initialQueueSub); // Update URL when tab changes const handleTabChange = (tab: TabType) => { setActiveTab(tab); if (tab === 'users') { setUsersSubTab('users'); } // Sub-tab state belongs to the tab being left for (const key of ['sub', 'view', 'group', 'printer']) searchParams.delete(key); if (tab === 'queue') { setQueueSubTab('dispatch'); } if (tab === 'general') { searchParams.delete('tab'); } else { searchParams.set('tab', tab); } setSearchParams(searchParams, { replace: true }); }; // Any other API Keys link, for a user who cannot see that tab, opens Camera: // the camera tokens were the only thing such a user ever went there for. // Waits for the session so a permission not loaded yet doesn't count as none. useEffect(() => { if (!authLoading && activeTab === 'apikeys' && !canSeeApiKeysTab) handleTabChange('camera'); // eslint-disable-next-line react-hooks/exhaustive-deps }, [authLoading, activeTab, canSeeApiKeysTab]); // Switch the Workflow tab's sub-tab and reflect it in the URL so deep-links work. const handleQueueSubTabChange = (sub: 'dispatch' | 'pipelines') => { setQueueSubTab(sub); if (sub === 'pipelines') { searchParams.set('sub', 'pipelines'); } else { searchParams.delete('sub'); } setSearchParams(searchParams, { replace: true }); }; const [showCreateAPIKey, setShowCreateAPIKey] = useState(false); const [newAPIKeyName, setNewAPIKeyName] = useState(''); const [newAPIKeyPermissions, setNewAPIKeyPermissions] = useState({ can_queue: true, can_control_printer: false, can_read_status: true, can_manage_library: true, can_manage_inventory: true, can_manage_maintenance: true, can_manage_archives: true, can_manage_projects: true, can_access_cloud: false, can_update_energy_cost: false, can_send_notifications: false, }); const [createdAPIKey, setCreatedAPIKey] = useState(null); const [showApiKeyQR, setShowApiKeyQR] = useState(false); const [showDeleteAPIKeyConfirm, setShowDeleteAPIKeyConfirm] = useState(null); const [testApiKey, setTestApiKey] = useState(''); // Confirm modal states const [showClearLogsConfirm, setShowClearLogsConfirm] = useState(false); const [showClearStorageConfirm, setShowClearStorageConfirm] = useState(false); const [showBulkPlugConfirm, setShowBulkPlugConfirm] = useState<'on' | 'off' | null>(null); const [showReleaseNotes, setShowReleaseNotes] = useState(false); const [showDisableAuthConfirm, setShowDisableAuthConfirm] = useState(false); const [showChangePasswordModal, setShowChangePasswordModal] = useState(false); const [changePasswordData, setChangePasswordData] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' }); const [changePasswordLoading, setChangePasswordLoading] = useState(false); const [storageUsageRefreshing, setStorageUsageRefreshing] = useState(false); // User management state const [showCreateUserModal, setShowCreateUserModal] = useState(false); // Local / LDAP tab inside the create-user modal (#1298). const [createUserTab, setCreateUserTab] = useState<'local' | 'ldap'>('local'); const [showEditUserModal, setShowEditUserModal] = useState(false); const [editingUserId, setEditingUserId] = useState(null); const [deleteUserId, setDeleteUserId] = useState(null); const [deleteUserItemCounts, setDeleteUserItemCounts] = useState<{ archives: number; queue_items: number; library_files: number } | null>(null); const [deleteUserLoading, setDeleteUserLoading] = useState(false); const [userFormData, setUserFormData] = useState<{ username: string; password?: string; email?: string; confirmPassword: string; role: string; group_ids: number[]; }>({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [], }); // Group management state const [deleteGroupId, setDeleteGroupId] = useState(null); // Home Assistant test connection state const [haTestResult, setHaTestResult] = useState<{ success: boolean; message: string | null; error: string | null } | null>(null); const [haTestLoading, setHaTestLoading] = useState(false); // External camera test state const [extCameraTestResults, setExtCameraTestResults] = useState>({}); const [extCameraTestLoading, setExtCameraTestLoading] = useState>({}); const handleDefaultViewChange = (path: string) => { setDefaultViewState(path); setDefaultView(path); showToast(t('settings.toast.settingsSaved'), 'success'); }; const { data: settings, isLoading } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings, }); const { data: storageUsage, isLoading: storageUsageLoading, isFetching: storageUsageFetching, } = useQuery({ queryKey: ['storage-usage'], queryFn: () => api.getStorageUsage(), enabled: activeTab === 'general', staleTime: Infinity, refetchInterval: false, refetchOnWindowFocus: false, refetchOnReconnect: false, }); // Price sensors to suggest when the electricity price comes from Home // Assistant (#1251): anything with a unit per kWh, MWh or Wh. const { data: haPriceEntities } = useQuery({ queryKey: ['ha-price-entities'], queryFn: async () => (await api.getBindableHAEntities()).filter( (e) => e.domain === 'sensor' && /\/\s*[km]?wh$/i.test(e.unit_of_measurement ?? ''), ), enabled: activeTab === 'general' && settings?.energy_price_source === 'homeassistant' && !!settings?.ha_enabled, staleTime: 60_000, retry: false, }); const handleStorageUsageRefresh = async () => { setStorageUsageRefreshing(true); try { const data = await api.getStorageUsage({ refresh: true }); queryClient.setQueryData(['storage-usage'], data); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to refresh storage usage'; showToast(message, 'error'); } finally { setStorageUsageRefreshing(false); } }; const { data: smartPlugs, isLoading: plugsLoading } = useQuery({ queryKey: ['smart-plugs'], queryFn: api.getSmartPlugs, }); // Fetch energy data for all smart plugs when on the plugs tab const { data: plugEnergySummary, isLoading: energyLoading } = useQuery({ queryKey: ['smart-plugs-energy', smartPlugs?.map(p => p.id)], queryFn: async () => { if (!smartPlugs || smartPlugs.length === 0) return null; const statuses = await Promise.all( smartPlugs.filter(p => p.enabled).map(async (plug) => { try { const status = await api.getSmartPlugStatus(plug.id); return { plug, status }; } catch { return { plug, status: null as SmartPlugStatus | null }; } }) ); // Aggregate energy data let totalPower = 0; let totalToday = 0; let totalYesterday = 0; let totalLifetime = 0; let reachableCount = 0; for (const { plug, status } of statuses) { // For MQTT plugs, consider reachable if we have power data const hasMqttData = plug.plug_type === 'mqtt' && (status?.energy?.power != null); // Reachability is about the device answering, not about it measuring // anything. Requiring `status.energy` here counted a perfectly online // switch-only plug -- no power sensor, so the backend leaves `energy` // null -- as offline, and the counter labelled "plugs online" quietly // reported "plugs reporting energy" instead (#2859). const isReachable = status?.reachable || hasMqttData; if (isReachable) { reachableCount++; if (status?.energy?.power != null) totalPower += status.energy.power; if (status?.energy?.today != null) totalToday += status.energy.today; if (status?.energy?.yesterday != null) totalYesterday += status.energy.yesterday; if (status?.energy?.total != null) totalLifetime += status.energy.total; } } return { totalPower, totalToday, totalYesterday, totalLifetime, reachableCount, totalPlugs: smartPlugs.filter(p => p.enabled).length, }; }, enabled: activeTab === 'plugs' && !!smartPlugs && smartPlugs.length > 0, refetchInterval: activeTab === 'plugs' ? 10000 : false, // Refresh every 10s when on plugs tab }); const { data: notificationProviders, isLoading: providersLoading } = useQuery({ queryKey: ['notification-providers'], queryFn: api.getNotificationProviders, }); const { data: apiKeys, isLoading: apiKeysLoading } = useQuery({ queryKey: ['api-keys'], queryFn: api.getAPIKeys, }); const createAPIKeyMutation = useMutation({ mutationFn: (data: { name: string; can_queue: boolean; can_control_printer: boolean; can_read_status: boolean; can_manage_library: boolean; can_manage_inventory: boolean; can_manage_maintenance: boolean; can_manage_archives: boolean; can_manage_projects: boolean; can_access_cloud: boolean }) => api.createAPIKey(data), onSuccess: (data) => { setCreatedAPIKey(data.key || null); setShowCreateAPIKey(false); setNewAPIKeyName(''); queryClient.invalidateQueries({ queryKey: ['api-keys'] }); showToast(t('settings.toast.apiKeyCreated')); }, onError: (error: Error) => { showToast(`Failed to create API key: ${error.message}`, 'error'); }, }); const deleteAPIKeyMutation = useMutation({ mutationFn: (id: number) => api.deleteAPIKey(id), onSuccess: (_data, deletedId) => { queryClient.setQueryData(['api-keys'], (old) => (old ?? []).filter((key) => key.id !== deletedId) ); showToast(t('settings.toast.apiKeyDeleted')); }, onError: (error: Error) => { showToast(`Failed to delete API key: ${error.message}`, 'error'); }, }); const { data: printers } = useQuery({ queryKey: ['printers'], queryFn: api.getPrinters, }); const { data: haSensors } = useQuery({ queryKey: ['haSensors'], queryFn: () => api.getHASensors(), }); // Not polled — this is the sensor list (for the badge count and the // section below), not live readings. It only changes via create/edit/ // delete, which already invalidate this key. const { data: locationHaSensors } = useQuery({ queryKey: ['locationHaSensors'], queryFn: () => api.getLocationHASensors(), }); const { data: haSensorLocations } = useQuery({ queryKey: inventoryLocationsQueryKey, queryFn: api.getLocations, enabled: activeTab === 'sensors', }); const locationSensorLocationIds = useMemo( () => Array.from(new Set((locationHaSensors ?? []).map((s) => s.location_id))), [locationHaSensors] ); // Live readings (reachable-aware), fetched per location the same way // InventoryPage's table view and SpoolLocationFooter do — false to get // every bound sensor here, not just the ones marked to show on the // filament card. Same query key as those two (no 'all'/'cardOnly' suffix), // so navigating here after Inventory has already fetched a location is a // cache hit instead of a second request; navigating back does the same // for Inventory. Only runs while this tab is actually open. const locationSensorReadingsQueries = useQueries({ queries: locationSensorLocationIds.map((locationId) => ({ queryKey: ['locationHaSensorReadings', locationId], queryFn: () => api.getLocationHASensorReadings(locationId, false), enabled: activeTab === 'sensors', refetchInterval: activeTab === 'sensors' ? (settings?.location_sensor_poll_interval || 120) * 1000 : false, })), }); const locationSensorReadingsById = useMemo(() => { const map = new Map(); for (const query of locationSensorReadingsQueries) { for (const reading of query.data ?? []) map.set(reading.id, reading); } return map; }, [locationSensorReadingsQueries]); const deleteLocationSensorsMutation = useMutation({ mutationFn: async (sensorIds: number[]) => { for (const id of sensorIds) { await api.deleteLocationHASensor(id); } }, onSuccess: () => { showToast(t('locationHaSensors.toast.deleted'), 'success'); }, onError: (err: Error) => { showToast(err.message, 'error'); }, // The mutation deletes sequentially; a failure partway through has // already deleted some sensors on the backend, so the cache needs // refreshing whether the mutation as a whole succeeded or failed — // otherwise the ones that did go through stay on screen as if nothing // happened. onSettled: () => { queryClient.invalidateQueries({ queryKey: ['locationHaSensors'] }); queryClient.invalidateQueries({ queryKey: ['locationHaSensorReadings'] }); setDeleteLocationSensorsTarget(null); }, }); // A business-sized fleet gets the commercial ask instead of the donation ask. const sponsorAudience = fleetAudience(printers?.length ?? 0); const { data: notificationTemplates, isLoading: templatesLoading } = useQuery({ queryKey: ['notification-templates'], queryFn: api.getNotificationTemplates, }); // Virtual printer status for tab indicator const { data: virtualPrinterSettings } = useQuery({ queryKey: ['virtual-printer-settings'], queryFn: virtualPrinterApi.getSettings, refetchInterval: 10000, }); const virtualPrinterRunning = virtualPrinterSettings?.status?.running ?? false; // SpoolBuddy devices for tab indicator const { data: spoolbuddyDevices } = useQuery({ queryKey: ['spoolbuddy-devices'], queryFn: () => spoolbuddyApi.getDevices(), refetchInterval: 15000, }); const spoolbuddyDeviceCount = spoolbuddyDevices?.length ?? 0; const spoolbuddyAnyOnline = spoolbuddyDevices?.some((d) => d.online) ?? false; // Obico failure-detection service status for tab indicator const { data: obicoStatus } = useQuery({ queryKey: ['obico-status'], queryFn: api.getObicoStatus, refetchInterval: 15000, }); const obicoActive = !!(obicoStatus?.is_running && obicoStatus?.enabled); const { data: ffmpegStatus } = useQuery({ queryKey: ['ffmpeg-status'], queryFn: api.checkFfmpeg, }); const { data: versionInfo } = useQuery({ queryKey: ['version'], queryFn: api.getVersion, }); // Library trash settings (#1008). Separate endpoint from the generic // /settings — persists retention window + auto-purge config. Admin-only. const canPurge = !authEnabled || hasPermission('library:purge'); const { data: trashSettings } = useQuery({ queryKey: ['library-trash-settings'], queryFn: () => api.getLibraryTrashSettings(), enabled: canPurge, }); const updateTrashSettingsMutation = useMutation({ mutationFn: (body: { retention_days: number; auto_purge_enabled: boolean; auto_purge_days: number; auto_purge_include_never_printed: boolean; }) => api.updateLibraryTrashSettings(body), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['library-trash-settings'] }); showToast(t('settings.toast.settingsSaved'), 'success'); }, onError: (e: Error) => showToast(e.message || t('libraryAutoPurge.saveFailed'), 'error'), }); const saveTrashSettings = (patch: Partial<{ retention_days: number; auto_purge_enabled: boolean; auto_purge_days: number; auto_purge_include_never_printed: boolean; }>) => { if (!trashSettings) return; updateTrashSettingsMutation.mutate({ retention_days: trashSettings.retention_days, auto_purge_enabled: trashSettings.auto_purge_enabled, auto_purge_days: trashSettings.auto_purge_days, auto_purge_include_never_printed: trashSettings.auto_purge_include_never_printed, ...patch, }); }; // Archive auto-purge (#1008 follow-up). Gated on the dedicated archives:purge // permission so admins can delegate bulk-delete to a role without granting // per-archive delete on other users' rows. const canPurgeArchives = !authEnabled || hasPermission('archives:purge'); const { data: archivePurgeSettings } = useQuery({ queryKey: ['archive-purge-settings'], queryFn: () => api.getArchivePurgeSettings(), enabled: canPurgeArchives, }); const updateArchivePurgeSettingsMutation = useMutation({ mutationFn: (body: { enabled: boolean; days: number; purge_stats: boolean }) => api.updateArchivePurgeSettings(body), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['archive-purge-settings'] }); showToast(t('settings.toast.settingsSaved'), 'success'); }, onError: (e: Error) => showToast(e.message || t('archiveAutoPurge.saveFailed'), 'error'), }); const saveArchivePurgeSettings = ( patch: Partial<{ enabled: boolean; days: number; purge_stats: boolean }>, ) => { if (!archivePurgeSettings) return; updateArchivePurgeSettingsMutation.mutate({ enabled: archivePurgeSettings.enabled, days: archivePurgeSettings.days, purge_stats: archivePurgeSettings.purge_stats, ...patch, }); }; const { data: updateCheck, refetch: refetchUpdateCheck, isRefetching: isCheckingUpdate } = useQuery({ queryKey: ['updateCheck'], queryFn: api.checkForUpdates, enabled: settings?.check_updates !== false, staleTime: 5 * 60 * 1000, }); const { data: updateStatus, refetch: refetchUpdateStatus } = useQuery({ queryKey: ['updateStatus'], queryFn: api.getUpdateStatus, refetchInterval: (query) => { const status = query.state.data as UpdateStatus | undefined; // Poll while update is in progress if (status?.status === 'downloading' || status?.status === 'installing') { return 1000; } return false; }, }); // MQTT status for Network tab const { data: mqttStatus } = useQuery({ queryKey: ['mqtt-status'], queryFn: api.getMQTTStatus, refetchInterval: activeTab === 'network' ? 5000 : false, // Poll every 5s when on Network tab }); // GitHub backup status for Backup tab indicator const { data: githubBackupStatus } = useQuery({ queryKey: ['github-backup-status'], queryFn: api.getGitHubBackupStatus, }); // Cloud auth status for Backup tab indicator const { data: cloudAuthStatus } = useQuery({ queryKey: ['cloud-status'], queryFn: api.getCloudStatus, }); // Advanced auth status for user creation const { data: advancedAuthStatus = { advanced_auth_enabled: false, smtp_configured: false, local_login_enabled: true, autologin_provider_id: null } } = useQuery({ queryKey: ['advancedAuthStatus'], queryFn: () => api.getAdvancedAuthStatus(), }); const { data: ldapStatus } = useQuery({ queryKey: ['ldapStatus'], queryFn: () => api.getLDAPStatus(), }); // Tab-indicator queries: green bullet when 2FA is enabled for the current // user, or when at least one OIDC provider is configured and enabled. const { data: twoFAStatus } = useQuery({ queryKey: ['twoFAStatus'], queryFn: () => api.get2FAStatus(), }); const { data: oidcProvidersAll = [] } = useQuery({ queryKey: ['oidcProvidersAll'], queryFn: () => api.getOIDCProvidersAll(), enabled: isAdmin, }); // User management queries and mutations const { data: usersData = [], isLoading: usersLoading } = useQuery({ queryKey: ['users'], queryFn: () => api.getUsers(), enabled: authEnabled && hasPermission('users:read'), }); const { data: groupsData = [], isLoading: groupsLoading } = useQuery({ queryKey: ['groups'], queryFn: () => api.getGroups(), enabled: authEnabled && hasPermission('groups:read'), }); const createUserMutation = useMutation({ mutationFn: (data: UserCreate) => api.createUser(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }); queryClient.invalidateQueries({ queryKey: ['groups'] }); setShowCreateUserModal(false); setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] }); showToast(t('settings.toast.userCreated')); }, onError: (error: Error) => { showToast(error.message, 'error'); }, }); const updateUserMutation = useMutation({ mutationFn: ({ id, data }: { id: number; data: UserUpdate }) => api.updateUser(id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }); queryClient.invalidateQueries({ queryKey: ['groups'] }); setShowEditUserModal(false); setEditingUserId(null); setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] }); showToast(t('settings.toast.userUpdated')); }, onError: (error: Error) => { showToast(error.message, 'error'); }, }); const deleteUserMutation = useMutation({ mutationFn: ({ id, deleteItems }: { id: number; deleteItems: boolean }) => api.deleteUser(id, deleteItems), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }); showToast(t('settings.toast.userDeleted')); setDeleteUserId(null); setDeleteUserItemCounts(null); }, onError: (error: Error) => { showToast(error.message, 'error'); }, }); const resetPasswordMutation = useMutation({ mutationFn: (userId: number) => api.resetUserPassword({ user_id: userId }), onSuccess: (response) => { showToast(response.message, 'success'); }, onError: (error: Error) => { showToast(error.message, 'error'); }, }); // Function to initiate user deletion with item count check const handleDeleteUserClick = async (userId: number) => { setDeleteUserId(userId); setDeleteUserLoading(true); try { const counts = await api.getUserItemsCount(userId); setDeleteUserItemCounts(counts); } catch { // If we can't get counts, just proceed without showing item options setDeleteUserItemCounts({ archives: 0, queue_items: 0, library_files: 0 }); } finally { setDeleteUserLoading(false); } }; const deleteGroupMutation = useMutation({ mutationFn: (id: number) => api.deleteGroup(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['groups'] }); showToast(t('settings.toast.groupDeleted')); }, onError: (error: Error) => { showToast(error.message, 'error'); }, }); // User management handlers const handleCreateUser = () => { // Use the status from the query hook const advancedAuthEnabled = advancedAuthStatus?.advanced_auth_enabled || false; if (!userFormData.username) { showToast(t('settings.toast.fillRequiredFields'), 'error'); return; } // Email is required when advanced auth is enabled if (advancedAuthEnabled && !userFormData.email) { showToast('Email is required when advanced authentication is enabled', 'error'); return; } // Password validation only when advanced auth is disabled if (!advancedAuthEnabled) { if (!userFormData.password) { showToast(t('settings.toast.fillRequiredFields'), 'error'); return; } if (userFormData.password !== userFormData.confirmPassword) { showToast(t('settings.toast.passwordsDoNotMatch'), 'error'); return; } const complexityIssue = checkPasswordComplexity(userFormData.password); if (complexityIssue) { const issueToKey = { tooShort: 'settings.toast.passwordTooShort', needsUppercase: 'settings.toast.passwordNeedsUppercase', needsLowercase: 'settings.toast.passwordNeedsLowercase', needsDigit: 'settings.toast.passwordNeedsDigit', needsSpecial: 'settings.toast.passwordNeedsSpecial', } as const; showToast(t(issueToKey[complexityIssue]), 'error'); return; } } createUserMutation.mutate({ username: userFormData.username, password: advancedAuthEnabled ? undefined : userFormData.password, email: userFormData.email || undefined, role: userFormData.role, group_ids: userFormData.group_ids.length > 0 ? userFormData.group_ids : undefined, }); }; const handleUpdateUser = (id: number) => { if (userFormData.password) { if (userFormData.password !== userFormData.confirmPassword) { showToast(t('settings.toast.passwordsDoNotMatch'), 'error'); return; } const complexityIssue = checkPasswordComplexity(userFormData.password); if (complexityIssue) { const issueToKey = { tooShort: 'settings.toast.passwordTooShort', needsUppercase: 'settings.toast.passwordNeedsUppercase', needsLowercase: 'settings.toast.passwordNeedsLowercase', needsDigit: 'settings.toast.passwordNeedsDigit', needsSpecial: 'settings.toast.passwordNeedsSpecial', } as const; showToast(t(issueToKey[complexityIssue]), 'error'); return; } } const updateData: UserUpdate = { username: userFormData.username || undefined, password: userFormData.password || undefined, email: userFormData.email || undefined, role: userFormData.role, group_ids: userFormData.group_ids, }; if (!updateData.password) { delete updateData.password; } updateUserMutation.mutate({ id, data: updateData }); }; const startEditUser = (userToEdit: UserResponse) => { setEditingUserId(userToEdit.id); setUserFormData({ username: userToEdit.username, password: '', email: userToEdit.email || '', confirmPassword: '', role: userToEdit.role, group_ids: userToEdit.groups?.map(g => g.id) || [], }); setShowEditUserModal(true); }; const toggleUserGroup = (groupId: number) => { setUserFormData(prev => ({ ...prev, group_ids: prev.group_ids.includes(groupId) ? prev.group_ids.filter(id => id !== groupId) : [...prev.group_ids, groupId], })); }; const applyUpdateMutation = useMutation({ mutationFn: api.applyUpdate, onSuccess: (data) => { if (data.is_ha_addon || data.is_docker || data.is_windows_installer) { showToast(data.message, 'error'); } else { refetchUpdateStatus(); } }, }); // Test all notification providers const [testAllResult, setTestAllResult] = useState<{ tested: number; success: number; failed: number; results: Array<{ provider_id: number; provider_name: string; provider_type: string; success: boolean; message: string; }>; } | null>(null); const testAllMutation = useMutation({ mutationFn: api.testAllNotificationProviders, onSuccess: (data) => { setTestAllResult(data); queryClient.invalidateQueries({ queryKey: ['notification-providers'] }); if (data.failed === 0) { showToast(`All ${data.tested} providers tested successfully!`, 'success'); } else { showToast(`${data.success}/${data.tested} providers succeeded`, data.failed > 0 ? 'error' : 'success'); } }, onError: (error: Error) => { showToast(`Failed to test providers: ${error.message}`, 'error'); }, }); // Bulk action for smart plugs const bulkPlugActionMutation = useMutation({ mutationFn: async (action: 'on' | 'off') => { if (!smartPlugs) return { success: 0, failed: 0 }; const enabledPlugs = smartPlugs.filter(p => p.enabled); const results = await Promise.all( enabledPlugs.map(async (plug) => { try { await api.controlSmartPlug(plug.id, action); return { success: true }; } catch { return { success: false }; } }) ); return { success: results.filter(r => r.success).length, failed: results.filter(r => !r.success).length, }; }, onSuccess: (data, action) => { queryClient.invalidateQueries({ queryKey: ['smart-plugs'] }); queryClient.invalidateQueries({ queryKey: ['smart-plugs-energy'] }); if (data.failed === 0) { showToast(`All ${data.success} plugs turned ${action}`, 'success'); } else { showToast(`${data.success} plugs turned ${action}, ${data.failed} failed`, 'error'); } }, onError: (error: Error) => { showToast(`Failed: ${error.message}`, 'error'); }, }); // Ref for debounce timeout const saveTimeoutRef = useRef | null>(null); const pendingGcodeSnippetsRef = useRef(null); const isSavingRef = useRef(false); const isInitialLoadRef = useRef(true); // #2716: the last server snapshot this page reconciled with. It is what // makes "the user edited this field" a well-defined question: a field where // localSettings still equals the baseline has not been touched since that // reconcile, so a newer server value can be taken instead of the page's stale // copy being written back over it. Before this the debounced save diffed // against the live ['settings'] cache, which made a value changed on the // server -- another tab, another user, a backup restore, a refetch driven by // any of the ~30 other observers of the key -- indistinguishable from an edit, // and reverted it a few hundred ms later with no user interaction at all. const serverBaselineRef = useRef(null); // Sync local state when settings load useEffect(() => { if (settings && !localSettings) { // Auto-detect external_url from browser if not set const settingsWithExternalUrl = { ...settings, external_url: settings.external_url || window.location.origin, }; // The baseline is the raw server row, not this adjusted copy: a detected // external_url has to read as a local change so it still gets persisted. serverBaselineRef.current = settings; setLocalSettings(settingsWithExternalUrl); // Mark initial load complete after a short delay setTimeout(() => { isInitialLoadRef.current = false; }, 100); } }, [settings, localSettings]); // #2716: reconcile a moved server snapshot into the local copy. A field the // user has not touched since the last reconcile takes the server's value; a // field they have edited keeps theirs and is saved over it by the debounced // effect below, so the newer of the two writes wins either way. Declared // before that effect so the baseline has already moved by the time it // computes its diff in the same commit. useEffect(() => { const baseline = serverBaselineRef.current; if (!settings || !localSettings || !baseline || settings === baseline) { return; } const adopted: Record = {}; for (const key of Object.keys(settings) as (keyof AppSettings)[]) { if (settings[key] !== baseline[key] && localSettings[key] === baseline[key]) { adopted[key] = settings[key]; } } serverBaselineRef.current = settings; if (Object.keys(adopted).length > 0) { setLocalSettings(prev => (prev ? { ...prev, ...(adopted as Partial) } : prev)); } }, [settings, localSettings]); const updateMutation = useMutation({ mutationFn: api.updateSettings, onSuccess: (data) => { // #2716: the row we just saved becomes the snapshot to diff against. // The setQueryData below would normally get the effect above to do this, // but only if react-query hands back a new object; setting it here means // the baseline never lags behind a save regardless. serverBaselineRef.current = data; queryClient.setQueryData(['settings'], data); // Choosing a price sensor reads it on save; show that price (#1251). if (data.energy_price_source === 'homeassistant') { setLocalSettings(prev => (prev ? { ...prev, energy_cost_per_kwh: data.energy_cost_per_kwh } : prev)); } // Don't call setLocalSettings(data) here — it would overwrite in-progress // user input (e.g. typing a hostname) with the stale saved snapshot, // causing the text field to reset mid-typing. Instead, let the useEffect // re-compare the updated `settings` with current `localSettings` and // debounce-save any remaining differences. queryClient.invalidateQueries({ queryKey: ['archiveStats'] }); // /settings/ui-flags serves currency and the sidebar gates to users who // cannot read /settings. Nothing invalidated it, so a currency change // sat behind that query's own staleTime instead of showing up (#3123). queryClient.invalidateQueries({ queryKey: ['ui-flags'] }); // Switching announcements off (or to all users) changes who sees the // sidebar entry and the banner; don't wait for the next poll. queryClient.invalidateQueries({ queryKey: ['announcements'] }); showToast(t('settings.toast.settingsSaved'), 'success'); }, onError: (error: Error) => { showToast(`Failed to save: ${error.message}`, 'error'); // No localSettings rollback here — the existing comment above (see // onSuccess) already flags that overwriting localSettings would discard // in-progress user input (e.g. typing a hostname). The no-permission // loop is already prevented by the up-front guards in updateSetting and // in the debounced-save effect, so this onError path now only fires for // genuine server/network failures where preserving typed-in values is // the right call. }, onSettled: () => { // Reset saving flag when mutation completes (success or error) isSavingRef.current = false; }, }); const updatePrinterMutation = useMutation({ mutationFn: ({ id, data }: { id: number; data: Partial<{ external_camera_url: string | null; external_camera_type: string | null; external_camera_enabled: boolean; external_camera_snapshot_url: string | null; camera_rotation: number; camera_light_auto: boolean }> }) => api.updatePrinter(id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['printers'] }); showToast(t('settings.toast.cameraSettingsSaved'), 'success'); }, onError: (error: Error) => { showToast(`Failed to update printer: ${error.message}`, 'error'); }, }); // Debounced auto-save when localSettings change useEffect(() => { // Skip if initial load or no settings const baseline = serverBaselineRef.current; if (isInitialLoadRef.current || !localSettings || !settings || !baseline) { return; } // Safety net: skip auto-save entirely when the user lacks settings:update. // The actual user feedback (toast + revert) lives in updateSetting below, // which runs once per click. Doing it here as well would fire on every // React render since the debounced-save effect depends on non-stable refs. if (authEnabled && !hasPermission('settings:update')) { return; } // Check if there are actual changes const hasChanges = baseline.auto_archive !== localSettings.auto_archive || baseline.save_thumbnails !== localSettings.save_thumbnails || baseline.capture_finish_photo !== localSettings.capture_finish_photo || (baseline.finish_photo_restore_plate ?? true) !== (localSettings.finish_photo_restore_plate ?? true) || baseline.default_filament_cost !== localSettings.default_filament_cost || baseline.currency !== localSettings.currency || (localSettings.energy_price_source !== 'homeassistant' && baseline.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh) || baseline.energy_tracking_mode !== localSettings.energy_tracking_mode || (baseline.energy_price_source ?? 'fixed') !== (localSettings.energy_price_source ?? 'fixed') || (baseline.energy_price_ha_entity ?? '') !== (localSettings.energy_price_ha_entity ?? '') || baseline.check_updates !== localSettings.check_updates || (baseline.check_printer_firmware ?? true) !== (localSettings.check_printer_firmware ?? true) || (baseline.include_beta_updates ?? false) !== (localSettings.include_beta_updates ?? false) || (baseline.announcements_enabled ?? true) !== (localSettings.announcements_enabled ?? true) || (baseline.announcements_all_users ?? false) !== (localSettings.announcements_all_users ?? false) || (baseline.local_login_enabled ?? true) !== (localSettings.local_login_enabled ?? true) || baseline.notification_language !== localSettings.notification_language || (baseline.bed_cooled_threshold ?? 35) !== (localSettings.bed_cooled_threshold ?? 35) || baseline.ams_humidity_good !== localSettings.ams_humidity_good || baseline.ams_humidity_fair !== localSettings.ams_humidity_fair || baseline.ams_temp_good !== localSettings.ams_temp_good || baseline.ams_temp_fair !== localSettings.ams_temp_fair || (baseline.ams_temp_alarm ?? null) !== (localSettings.ams_temp_alarm ?? null) || baseline.ams_history_retention_days !== localSettings.ams_history_retention_days || baseline.disable_filament_warnings !== localSettings.disable_filament_warnings || baseline.prefer_lowest_filament !== localSettings.prefer_lowest_filament || (baseline.queue_drying_enabled ?? false) !== (localSettings.queue_drying_enabled ?? false) || (baseline.queue_drying_block ?? false) !== (localSettings.queue_drying_block ?? false) || (baseline.ambient_drying_enabled ?? false) !== (localSettings.ambient_drying_enabled ?? false) || (baseline.ambient_drying_sustained_minutes ?? 0) !== (localSettings.ambient_drying_sustained_minutes ?? 0) || (baseline.print_drying_enabled ?? false) !== (localSettings.print_drying_enabled ?? false) || (baseline.drying_presets ?? '') !== (localSettings.drying_presets ?? '') || (baseline.ams_humidity_thresholds ?? '') !== (localSettings.ams_humidity_thresholds ?? '') || baseline.per_printer_mapping_expanded !== localSettings.per_printer_mapping_expanded || baseline.date_format !== localSettings.date_format || baseline.time_format !== localSettings.time_format || baseline.default_printer_id !== localSettings.default_printer_id || baseline.ftp_retry_enabled !== localSettings.ftp_retry_enabled || baseline.ftp_retry_count !== localSettings.ftp_retry_count || baseline.ftp_retry_delay !== localSettings.ftp_retry_delay || baseline.ftp_timeout !== localSettings.ftp_timeout || baseline.mqtt_enabled !== localSettings.mqtt_enabled || baseline.mqtt_broker !== localSettings.mqtt_broker || baseline.mqtt_port !== localSettings.mqtt_port || baseline.mqtt_username !== localSettings.mqtt_username || baseline.mqtt_password !== localSettings.mqtt_password || baseline.mqtt_topic_prefix !== localSettings.mqtt_topic_prefix || baseline.mqtt_use_tls !== localSettings.mqtt_use_tls || baseline.external_url !== localSettings.external_url || baseline.ha_enabled !== localSettings.ha_enabled || baseline.ha_url !== localSettings.ha_url || baseline.ha_token !== localSettings.ha_token || (baseline.library_archive_mode ?? 'ask') !== (localSettings.library_archive_mode ?? 'ask') || Number(baseline.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5) || (baseline.camera_light_mode ?? 'off') !== (localSettings.camera_light_mode ?? 'off') || Number(baseline.camera_light_delay ?? 2) !== Number(localSettings.camera_light_delay ?? 2) || (baseline.preferred_slicer ?? 'bambu_studio') !== (localSettings.preferred_slicer ?? 'bambu_studio') || resolveEngine(baseline.slice_engine) !== resolveEngine(localSettings.slice_engine) || (baseline.open_in_slicer ?? null) !== (localSettings.open_in_slicer ?? null) || (baseline.use_slicer_api ?? false) !== (localSettings.use_slicer_api ?? false) || (baseline.orcaslicer_api_url ?? '') !== (localSettings.orcaslicer_api_url ?? '') || (baseline.slicer_stall_timeout_minutes ?? 15) !== (localSettings.slicer_stall_timeout_minutes ?? 15) || (baseline.bambu_studio_api_url ?? '') !== (localSettings.bambu_studio_api_url ?? '') || baseline.prometheus_enabled !== localSettings.prometheus_enabled || baseline.prometheus_token !== localSettings.prometheus_token || (baseline.user_notifications_enabled ?? true) !== (localSettings.user_notifications_enabled ?? true) || (baseline.default_bed_levelling ?? 'auto') !== (localSettings.default_bed_levelling ?? 'auto') || (baseline.default_flow_cali ?? 'auto') !== (localSettings.default_flow_cali ?? 'auto') || (baseline.default_vibration_cali ?? true) !== (localSettings.default_vibration_cali ?? true) || (baseline.default_layer_inspect ?? false) !== (localSettings.default_layer_inspect ?? false) || (baseline.default_timelapse ?? false) !== (localSettings.default_timelapse ?? false) || (baseline.default_nozzle_offset_cali ?? 'auto') !== (localSettings.default_nozzle_offset_cali ?? 'auto') || (baseline.default_confirm_outcome ?? false) !== (localSettings.default_confirm_outcome ?? false) || (baseline.confirm_outcome_external_prints ?? false) !== (localSettings.confirm_outcome_external_prints ?? false) || (baseline.confirm_default_good_on_plate_clear ?? false) !== (localSettings.confirm_default_good_on_plate_clear ?? false) || (baseline.stagger_group_size ?? 2) !== (localSettings.stagger_group_size ?? 2) || (baseline.stagger_interval_minutes ?? 5) !== (localSettings.stagger_interval_minutes ?? 5) || (baseline.require_plate_clear ?? false) !== (localSettings.require_plate_clear ?? false) || (baseline.queue_max_concurrent_uploads ?? 4) !== (localSettings.queue_max_concurrent_uploads ?? 4) || (baseline.preheat_enabled ?? false) !== (localSettings.preheat_enabled ?? false) || (baseline.preheat_filament_targets ?? '') !== (localSettings.preheat_filament_targets ?? '') || (baseline.preheat_max_wait_seconds ?? 900) !== (localSettings.preheat_max_wait_seconds ?? 900) || (baseline.preheat_soak_seconds ?? 300) !== (localSettings.preheat_soak_seconds ?? 300) || (baseline.queue_keep_bed_warm ?? false) !== (localSettings.queue_keep_bed_warm ?? false) || (baseline.queue_keep_warm_bed_temp ?? 90) !== (localSettings.queue_keep_warm_bed_temp ?? 90) || (baseline.queue_keep_warm_max_minutes ?? 120) !== (localSettings.queue_keep_warm_max_minutes ?? 120) || (baseline.nozzle_temp_presets ?? '') !== (localSettings.nozzle_temp_presets ?? '') || (baseline.bed_temp_presets ?? '') !== (localSettings.bed_temp_presets ?? '') || (baseline.chamber_temp_presets ?? '') !== (localSettings.chamber_temp_presets ?? '') || (baseline.fan_speed_presets ?? '') !== (localSettings.fan_speed_presets ?? '') || (baseline.session_max_hours ?? 24) !== (localSettings.session_max_hours ?? 24) || (baseline.billing_enabled ?? false) !== (localSettings.billing_enabled ?? false) || (baseline.printer_kill_switch_enabled ?? false) !== (localSettings.printer_kill_switch_enabled ?? false) || (baseline.finance_budget_reset_day ?? 1) !== (localSettings.finance_budget_reset_day ?? 1) || (baseline.finance_budget_reset_timezone ?? 'UTC') !== (localSettings.finance_budget_reset_timezone ?? 'UTC'); if (!hasChanges) { return; } // Don't queue more saves while one is in progress if (isSavingRef.current) { return; } // Clear existing timeout if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); } // Set new debounced save (500ms delay) saveTimeoutRef.current = setTimeout(() => { // Skip if a save is already in progress if (isSavingRef.current) { return; } isSavingRef.current = true; // Only send the fields we manage on this page (exclude virtual_printer_* which are managed separately) const settingsToSave: AppSettingsUpdate = { auto_archive: localSettings.auto_archive, save_thumbnails: localSettings.save_thumbnails, capture_finish_photo: localSettings.capture_finish_photo, // #2547: `?? true` mirrors the toggle's own default, so an install // whose settings payload predates this field saves what the user is // actually looking at rather than `undefined`. finish_photo_restore_plate: localSettings.finish_photo_restore_plate ?? true, default_filament_cost: localSettings.default_filament_cost, currency: localSettings.currency, // A price read from Home Assistant is the server's to set; the copy // here can be an hour old and would overwrite a newer reading (#1251). ...(localSettings.energy_price_source === 'homeassistant' ? {} : { energy_cost_per_kwh: localSettings.energy_cost_per_kwh }), energy_tracking_mode: localSettings.energy_tracking_mode, // Normalised: anything but 'homeassistant' (a value restored from a // backup) is fixed, and the backend refuses unknown values outright. energy_price_source: localSettings.energy_price_source === 'homeassistant' ? 'homeassistant' : 'fixed', energy_price_ha_entity: localSettings.energy_price_ha_entity ?? '', check_updates: localSettings.check_updates, check_printer_firmware: localSettings.check_printer_firmware, include_beta_updates: localSettings.include_beta_updates, announcements_enabled: localSettings.announcements_enabled ?? true, announcements_all_users: localSettings.announcements_all_users ?? false, local_login_enabled: localSettings.local_login_enabled, notification_language: localSettings.notification_language, bed_cooled_threshold: localSettings.bed_cooled_threshold, ams_humidity_good: localSettings.ams_humidity_good, ams_humidity_fair: localSettings.ams_humidity_fair, ams_temp_good: localSettings.ams_temp_good, ams_temp_fair: localSettings.ams_temp_fair, ams_temp_alarm: localSettings.ams_temp_alarm ?? null, ams_history_retention_days: localSettings.ams_history_retention_days, disable_filament_warnings: localSettings.disable_filament_warnings, prefer_lowest_filament: localSettings.prefer_lowest_filament, queue_drying_enabled: localSettings.queue_drying_enabled, queue_drying_block: localSettings.queue_drying_block, ambient_drying_enabled: localSettings.ambient_drying_enabled, ambient_drying_sustained_minutes: localSettings.ambient_drying_sustained_minutes, print_drying_enabled: localSettings.print_drying_enabled, drying_presets: localSettings.drying_presets, ams_humidity_thresholds: localSettings.ams_humidity_thresholds, per_printer_mapping_expanded: localSettings.per_printer_mapping_expanded, date_format: localSettings.date_format, time_format: localSettings.time_format, default_printer_id: localSettings.default_printer_id, ftp_retry_enabled: localSettings.ftp_retry_enabled, ftp_retry_count: localSettings.ftp_retry_count, ftp_retry_delay: localSettings.ftp_retry_delay, ftp_timeout: localSettings.ftp_timeout, mqtt_enabled: localSettings.mqtt_enabled, mqtt_broker: localSettings.mqtt_broker, mqtt_port: localSettings.mqtt_port, mqtt_username: localSettings.mqtt_username, mqtt_password: localSettings.mqtt_password, mqtt_topic_prefix: localSettings.mqtt_topic_prefix, mqtt_use_tls: localSettings.mqtt_use_tls, external_url: localSettings.external_url, ha_enabled: localSettings.ha_enabled, ha_url: localSettings.ha_url, ha_token: localSettings.ha_token, library_archive_mode: localSettings.library_archive_mode, library_disk_warning_gb: localSettings.library_disk_warning_gb, camera_light_mode: localSettings.camera_light_mode, camera_light_delay: localSettings.camera_light_delay, preferred_slicer: localSettings.preferred_slicer, slice_engine: localSettings.slice_engine, open_in_slicer: localSettings.open_in_slicer, use_slicer_api: localSettings.use_slicer_api, orcaslicer_api_url: localSettings.orcaslicer_api_url, slicer_stall_timeout_minutes: localSettings.slicer_stall_timeout_minutes, bambu_studio_api_url: localSettings.bambu_studio_api_url, prometheus_enabled: localSettings.prometheus_enabled, prometheus_token: localSettings.prometheus_token, user_notifications_enabled: localSettings.user_notifications_enabled, default_bed_levelling: localSettings.default_bed_levelling, default_flow_cali: localSettings.default_flow_cali, default_vibration_cali: localSettings.default_vibration_cali, default_layer_inspect: localSettings.default_layer_inspect, default_timelapse: localSettings.default_timelapse, default_nozzle_offset_cali: localSettings.default_nozzle_offset_cali, default_confirm_outcome: localSettings.default_confirm_outcome, confirm_outcome_external_prints: localSettings.confirm_outcome_external_prints, confirm_default_good_on_plate_clear: localSettings.confirm_default_good_on_plate_clear, stagger_group_size: localSettings.stagger_group_size, stagger_interval_minutes: localSettings.stagger_interval_minutes, require_plate_clear: localSettings.require_plate_clear, queue_max_concurrent_uploads: localSettings.queue_max_concurrent_uploads, preheat_enabled: localSettings.preheat_enabled, preheat_filament_targets: localSettings.preheat_filament_targets, preheat_max_wait_seconds: localSettings.preheat_max_wait_seconds, preheat_soak_seconds: localSettings.preheat_soak_seconds, queue_keep_bed_warm: localSettings.queue_keep_bed_warm, queue_keep_warm_bed_temp: localSettings.queue_keep_warm_bed_temp, queue_keep_warm_max_minutes: localSettings.queue_keep_warm_max_minutes, nozzle_temp_presets: localSettings.nozzle_temp_presets, bed_temp_presets: localSettings.bed_temp_presets, chamber_temp_presets: localSettings.chamber_temp_presets, fan_speed_presets: localSettings.fan_speed_presets, session_max_hours: localSettings.session_max_hours, billing_enabled: localSettings.billing_enabled, printer_kill_switch_enabled: localSettings.printer_kill_switch_enabled, finance_budget_reset_day: localSettings.finance_budget_reset_day, finance_budget_reset_timezone: localSettings.finance_budget_reset_timezone, }; updateMutation.mutate(settingsToSave); }, 500); // Cleanup on unmount or when localSettings changes again return () => { if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); } }; }, [localSettings, settings, updateMutation, authEnabled, hasPermission, showToast, t]); const updateSetting = useCallback((key: K, value: AppSettings[K]) => { // Gate at the point of user interaction (not in the debounced-save effect — // that runs on every render and would fire the toast repeatedly). One toast // per attempt; no local state divergence for a read-only delegated user. if (authEnabled && !hasPermission('settings:update')) { showToast(t('settings.toast.noPermissionUpdate'), 'error'); return; } setLocalSettings(prev => prev ? { ...prev, [key]: value } : null); }, [authEnabled, hasPermission, showToast, t]); const handleTestExternalCamera = async (printerId: number, url: string, cameraType: string) => { if (!url) { showToast(t('settings.toast.enterCameraUrl'), 'error'); return; } setExtCameraTestLoading(prev => ({ ...prev, [printerId]: true })); setExtCameraTestResults(prev => ({ ...prev, [printerId]: null })); try { const result = await api.testExternalCamera(printerId, url, cameraType); setExtCameraTestResults(prev => ({ ...prev, [printerId]: result })); if (result.success) { // A shared capture means the frame is real but was not fetched over a // connection this test opened, so say so rather than implying the // camera was just reached. showToast( result.coalesced ? t('settings.toast.cameraConnectedCoalesced', { resolution: result.resolution || '' }) : t('settings.toast.cameraConnected', { resolution: result.resolution || '' }), 'success' ); } else { showToast(result.error || t('settings.toast.connectionFailed'), 'error'); } } catch (error) { const message = error instanceof Error ? error.message : t('settings.toast.testFailed'); setExtCameraTestResults(prev => ({ ...prev, [printerId]: { success: false, error: message } })); showToast(message, 'error'); } finally { setExtCameraTestLoading(prev => ({ ...prev, [printerId]: false })); } }; // Local state for camera URL inputs (to avoid saving on every keystroke) const [localCameraUrls, setLocalCameraUrls] = useState>({}); const cameraUrlSaveTimeoutRef = useRef>>({}); const initializedPrinterUrlsRef = useRef>(new Set()); const [localSnapshotUrls, setLocalSnapshotUrls] = useState>({}); const snapshotUrlSaveTimeoutRef = useRef>>({}); const [openCameraPrinterId, setOpenCameraPrinterId] = useState(null); const initializedPrinterSnapshotUrlsRef = useRef>(new Set()); // Initialize local camera URLs from printer data useEffect(() => { if (printers) { const urls: Record = {}; const snapUrls: Record = {}; printers.forEach(p => { if (p.external_camera_url && !initializedPrinterUrlsRef.current.has(p.id)) { urls[p.id] = p.external_camera_url; initializedPrinterUrlsRef.current.add(p.id); } if (p.external_camera_snapshot_url && !initializedPrinterSnapshotUrlsRef.current.has(p.id)) { snapUrls[p.id] = p.external_camera_snapshot_url; initializedPrinterSnapshotUrlsRef.current.add(p.id); } }); if (Object.keys(urls).length > 0) { setLocalCameraUrls(prev => ({ ...prev, ...urls })); } if (Object.keys(snapUrls).length > 0) { setLocalSnapshotUrls(prev => ({ ...prev, ...snapUrls })); } } }, [printers]); const handleCameraUrlChange = (printerId: number, url: string) => { // Update local state immediately for responsive UI setLocalCameraUrls(prev => ({ ...prev, [printerId]: url })); // Clear existing timeout for this printer if (cameraUrlSaveTimeoutRef.current[printerId]) { clearTimeout(cameraUrlSaveTimeoutRef.current[printerId]); } // Debounce the save (800ms delay) cameraUrlSaveTimeoutRef.current[printerId] = setTimeout(() => { updatePrinterMutation.mutate({ id: printerId, data: { external_camera_url: url || null } }); }, 800); }; const handleSnapshotUrlChange = (printerId: number, url: string) => { setLocalSnapshotUrls(prev => ({ ...prev, [printerId]: url })); if (snapshotUrlSaveTimeoutRef.current[printerId]) { clearTimeout(snapshotUrlSaveTimeoutRef.current[printerId]); } snapshotUrlSaveTimeoutRef.current[printerId] = setTimeout(() => { updatePrinterMutation.mutate({ id: printerId, data: { external_camera_snapshot_url: url || null } }); }, 800); }; const handleUpdatePrinterCamera = (printerId: number, updates: { type?: string; enabled?: boolean; rotation?: number; lightAuto?: boolean }) => { const data: Partial<{ external_camera_type: string | null; external_camera_enabled: boolean; camera_rotation: number; camera_light_auto: boolean }> = {}; if (updates.type !== undefined) data.external_camera_type = updates.type || null; if (updates.enabled !== undefined) data.external_camera_enabled = updates.enabled; if (updates.rotation !== undefined) data.camera_rotation = updates.rotation; if (updates.lightAuto !== undefined) data.camera_light_auto = updates.lightAuto; updatePrinterMutation.mutate({ id: printerId, data }); }; if (isLoading || !localSettings) { return (
); } // Cross-tab search is powered by the module-level registry in lib/settingsSearch. // Resolve i18n labels here so language changes take effect without re-registering. const searchIndex = getSettingsSearchEntries().map(e => ({ ...e, label: t(e.labelKey, e.labelFallback ?? e.labelKey), })); const searchQuery = settingsSearch.trim().toLowerCase(); const searchResults = searchQuery ? searchIndex.filter( e => e.label.toLowerCase().includes(searchQuery) || e.keywords.toLowerCase().includes(searchQuery) ).slice(0, 8) : []; const jumpToSetting = (entry: typeof searchIndex[number]) => { handleTabChange(entry.tab as TabType); if (entry.subTab) { selectUsersSubTab(entry.subTab as UsersSubTab); } setSettingsSearch(''); // Scroll to the card after the tab has rendered setTimeout(() => { const el = document.getElementById(entry.anchor); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'start' }); el.classList.add('ring-2', 'ring-bambu-green'); setTimeout(() => el.classList.remove('ring-2', 'ring-bambu-green'), 1500); } }, 50); }; // #2664 (reporter pchulpjoost): `docker compose pull` only works from the // directory holding the compose file, so the bare command the update box // printed could not be pasted anywhere useful. The saved setting wins; when // it is blank we fall back to whatever the backend could infer, and when // that is blank too we print the command without a `cd` rather than a made-up // path that would fail on paste. const composeDir = (localSettings?.docker_compose_dir ?? '').trim() || (updateCheck?.compose_dir_detected ?? ''); // Quoted only when it has to be. The backend restricts this field to path // characters, so quotes, $ and backticks cannot be in it and double-quoting // a path with a space is safe. const composeUpdateCommand = composeDir ? `cd ${/\s/.test(composeDir) ? `"${composeDir}"` : composeDir} && docker compose pull && docker compose up -d` : 'docker compose pull && docker compose up -d'; const cameraLightMode = localSettings.camera_light_mode ?? 'off'; const lightPrinters = (printers ?? []).filter(p => p.camera_light_auto); const externalCameraPrinters = (printers ?? []).filter(p => p.external_camera_enabled); const countBadge = (count: number) => count > 0 && ( {count} ); const statusDot = (on: boolean) => ( ); const haSensorCount = (haSensors?.length ?? 0) + (locationHaSensors?.length ?? 0); // General stays first, where most visits start; the rest are sorted by their // label in the current language so each language reads alphabetically. const generalTab: SettingsTabEntry = { tab: 'general', icon: SettingsIcon, label: t('settings.tabs.general') }; const otherTabs = ([ { tab: 'plugs', icon: Plug, label: t('settings.tabs.smartPlugs'), extra: countBadge(smartPlugs?.length ?? 0) }, { tab: 'sensors', icon: Gauge, label: t('settings.tabs.sensors'), extra: countBadge(haSensorCount) }, { tab: 'notifications', icon: Bell, label: t('settings.tabs.notifications'), extra: countBadge(notificationProviders?.length ?? 0) }, { tab: 'queue', icon: ListOrdered, label: t('settings.tabs.queue', 'Workflow') }, { tab: 'filament', icon: Cylinder, label: t('settings.tabs.filament') }, { tab: 'camera', icon: Video, label: t('settings.tabs.camera') }, { tab: 'network', icon: Wifi, label: t('settings.tabs.network'), extra: statusDot(!!mqttStatus?.enabled) }, // API Keys holds API keys, webhooks, the API browser and Connected Apps; // a user who may see none of them would land on an empty tab. ...(canSeeApiKeysTab ? [{ tab: 'apikeys', icon: Key, label: t('settings.tabs.apiKeys'), extra: countBadge(apiKeys?.length ?? 0) } as SettingsTabEntry] : []), { tab: 'virtual-printer', icon: Printer, label: t('settings.tabs.virtualPrinter'), extra: statusDot(!!virtualPrinterRunning) }, { tab: 'spoolbuddy', icon: Scale, label: t('settings.tabs.spoolbuddy'), extra: <>{countBadge(spoolbuddyDeviceCount)}{statusDot(!!spoolbuddyAnyOnline)} }, { tab: 'failure-detection', icon: ScanEye, label: t('settings.tabs.failureDetection'), extra: statusDot(!!obicoActive) }, { tab: 'users', icon: Users, label: t('settings.tabs.users'), extra: authEnabled && }, { tab: 'backup', icon: Database, label: t('settings.tabs.backup'), extra: statusDot(!!((cloudAuthStatus?.is_authenticated && githubBackupStatus?.configured && githubBackupStatus?.enabled) || settings?.local_backup_enabled)) }, ] as SettingsTabEntry[]).sort((x, y) => x.label.localeCompare(y.label, i18n.language)); return (

{t('settings.title')}

{t('settings.configureBambuddy')}

{/* Cross-tab search */}
setSettingsSearch(e.target.value)} placeholder={t('settings.searchPlaceholder', 'Search settings…')} className="w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green" /> {settingsSearch && ( )} {searchResults.length > 0 && (
{searchResults.map((entry) => ( ))}
)} {searchQuery && searchResults.length === 0 && (

{t('settings.noSearchResults', 'No matching settings.')}

)}
{/* Tab Navigation + content: horizontal tabs on mobile, vertical rail on lg+ */}
{activeTab === 'general' && ( <> {/* Sponsor banner — independence callout, or the commercial ask on a business-sized fleet (see utils/fleetAudience). */}
{sponsorAudience === 'business' ? : }

{sponsorAudience === 'business' ? t('sponsors.businessTitle', 'Bambuddy for business') : t('sponsors.sectionTitle', 'Independent & community-funded')}

{sponsorAudience === 'business' ? t( 'sponsors.businessTagline', "You're running {{count}} printers. Priority support, commercial licensing and invoicing are available for teams and print farms.", { count: printers?.length ?? 0 } ) : t( 'sponsors.tagline', 'Bambuddy is free and stays that way because people choose to support it. No VC, no cloud lock-in.' )}

{sponsorAudience === 'business' ? t('sponsors.businessCta', 'Bambuddy for business') : t('sponsors.viewSupporters', 'View supporters')}
{/* Left Column - General Settings */}

{t('settings.general')}

{t('settings.languageDescription')}

{t('settings.defaultViewDescription')}

{t('settings.defaultPrinterDescription')}

{t('settings.appearance')}

{/* Theme Mode Selector */}
{([ { id: 'dark', label: t('settings.themeDark') }, { id: 'light', label: t('settings.themeLight') }, { id: 'system', label: t('settings.themeSystem') }, ] as const).map(({ id, label }) => ( ))}
{/* Dark Mode Settings */}

{t('settings.darkMode')} {resolvedMode === 'dark' && {t('settings.active')}}

{/* Light Mode Settings */}

{t('settings.lightMode')} {resolvedMode === 'light' && {t('settings.active')}}

{t('settings.themeToggleHint')}

{t('settings.progressInTitle')}

{t('settings.progressInTitleDescription')}

{t('settings.archiveSettings')}

{t('settings.autoArchivePrints')}

{t('settings.autoArchiveDescription')}

{t('settings.saveThumbnails')}

{t('settings.saveThumbnailsDescription')}

{t('settings.captureFinishPhoto')}

{t('settings.captureFinishPhotoDescription')}

{/* #2547: only meaningful while finish photos are being taken at all, so it hangs off the toggle above rather than standing alone in the list. */} {localSettings.capture_finish_photo && (

{t('settings.finishPhotoRestorePlate')}

{t('settings.finishPhotoRestorePlateDescription')}

)} {localSettings.capture_finish_photo && ffmpegStatus && !ffmpegStatus.installed && (

{t('settings.ffmpegNotInstalled')}

{t('settings.ffmpegRequired')}

)} {/* Archive auto-purge (#1008 follow-up). Admin-only — gated on archives:delete_all. Hard-deletes archives older than the configured age threshold once per 24h. */} {canPurgeArchives && archivePurgeSettings && (

{t('archiveAutoPurge.enableLabel')}

{t('archiveAutoPurge.enableDescription')}

saveArchivePurgeSettings({ days: Math.max(7, Math.min(3650, parseInt(e.target.value || '0', 10) || 0)), }) } className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none disabled:opacity-50" /> {t('archiveAutoPurge.days')}

{t('archiveAutoPurge.ageDescription')}

)}
{/* Second Column - Cost, AMS & Spoolman */}

{t('settings.costTracking')}

{getCurrencySymbol(localSettings.currency)} updateSetting('default_filament_cost', v)} integer={false} fallback={0} style={{ paddingLeft: `${Math.max(2, getCurrencySymbol(localSettings.currency).length * 0.6 + 1)}rem` }} className="w-full pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" />
{localSettings.energy_price_source === 'homeassistant' && !localSettings.ha_enabled && (

{t('settings.energyPriceHaNotConfigured')}

)}
{localSettings.energy_price_source === 'homeassistant' && (
updateSetting('energy_price_ha_entity', e.target.value.trim())} placeholder="sensor.electricity_price" className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none" /> {(haPriceEntities ?? []).map((e) => ( ))}
)}
{getCurrencySymbol(localSettings.currency)} updateSetting('energy_cost_per_kwh', v)} integer={false} fallback={0} disabled={localSettings.energy_price_source === 'homeassistant'} style={{ paddingLeft: `${Math.max(2, getCurrencySymbol(localSettings.currency).length * 0.6 + 1)}rem` }} className="w-full pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none disabled:opacity-60" />

{localSettings.energy_price_source === 'homeassistant' ? t('settings.energyPriceHaHint') : t('settings.energyPriceFixedHint')}

{localSettings.energy_tracking_mode === 'print' ? t('settings.energyModePrintDescription') : t('settings.energyModeTotalDescription')}

{t('settings.billingEnabled')}

{t('settings.billingEnabledDescription')}