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 } 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 { 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 { 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 { 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 } from 'react'; import { Gauge, Palette } from 'lucide-react'; import { registerSettingsSearch, getSettingsSearchEntries } from '../lib/settingsSearch'; import type { UsersSubTab } from '../lib/settingsSearch'; import { availableEngines, hasEngineChoice, resolveEngine, type SliceEngineId } from '../lib/sliceEngines'; const validTabs = ['general', 'plugs', 'sensors', 'notifications', 'queue', 'filament', 'network', 'apikeys', 'virtual-printer', 'spoolbuddy', 'failure-detection', 'users', 'backup'] as const; type TabType = typeof validTabs[number]; // 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: 'general', keywords: 'camera external video stream', 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: 'cameraTokens.title', tab: 'apikeys', keywords: 'camera token long-lived home assistant frigate kiosk stream', anchor: 'card-camera-tokens' }); 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: '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 }); } export function SettingsPage() { const queryClient = useQueryClient(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const { t, i18n } = useTranslation(); const { showToast } = useToast(); const { authEnabled, user, isAdmin, refreshAuth, hasPermission } = 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>({}); 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'; const initialTab = isLegacyEmailTab ? 'users' : (tabParam && validTabs.includes(tabParam as TabType) ? tabParam as TabType : 'general'); const [activeTab, setActiveTab] = useState(initialTab); const [usersSubTab, setUsersSubTab] = useState(isLegacyEmailTab ? 'email' : 'users'); // 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'); } if (tab === 'queue') { setQueueSubTab('dispatch'); searchParams.delete('sub'); } if (tab === 'general') { searchParams.delete('tab'); } else { searchParams.set('tab', tab); } setSearchParams(searchParams, { replace: true }); }; // 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, }); 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, }); 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); // 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'] }); 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 }> }) => 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 || baseline.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh || baseline.energy_tracking_mode !== localSettings.energy_tracking_mode || 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.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.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.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.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, energy_cost_per_kwh: localSettings.energy_cost_per_kwh, energy_tracking_mode: localSettings.energy_tracking_mode, check_updates: localSettings.check_updates, check_printer_firmware: localSettings.check_printer_firmware, include_beta_updates: localSettings.include_beta_updates, 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, 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, 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, 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 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 }) => { const data: Partial<{ external_camera_type: string | null; external_camera_enabled: boolean; camera_rotation: number }> = {}; 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; 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) { setUsersSubTab(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'; 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 - Camera, Cost, AMS & Spoolman */}
{/* Camera Settings */}

{/* Camera View Mode used to live here. It is now the split camera button on each printer card, where the choice is made per stream instead of once for the whole install. The stored setting stays as the default a fresh browser starts from. */} {/* External Cameras Section */}

{t('settings.externalCameras')}

{t('settings.externalCamerasDescription')}

{printers && printers.length > 0 ? (
{printers.map(printer => (
{printer.name}
{printer.external_camera_enabled && (
handleCameraUrlChange(printer.id, e.target.value)} className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none" />
{extCameraTestResults[printer.id] && (
{extCameraTestResults[printer.id]?.success ? ( <> {t('settings.connected')}{extCameraTestResults[printer.id]?.resolution && ` (${extCameraTestResults[printer.id]?.resolution})`} ) : ( <> {extCameraTestResults[printer.id]?.error || t('settings.toast.connectionFailed')} )}
)} {(printer.external_camera_type === 'mjpeg' || printer.external_camera_type === 'rtsp' || printer.external_camera_type === 'usb') && (
handleSnapshotUrlChange(printer.id, e.target.value)} className="flex-1 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none" />

{t('settings.cameraSnapshotUrlHelp', 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.')}

)}
)}
))}
) : (

{t('settings.noPrintersConfigured')}

)}

{t('settings.costTracking')}

{getCurrencySymbol(localSettings.currency)} updateSetting('default_filament_cost', parseFloat(e.target.value) || 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" />
{getCurrencySymbol(localSettings.currency)} updateSetting('energy_cost_per_kwh', parseFloat(e.target.value) || 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_tracking_mode === 'print' ? t('settings.energyModePrintDescription') : t('settings.energyModeTotalDescription')}

{t('settings.billingEnabled')}

{t('settings.billingEnabledDescription')}