import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, 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 } 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 type { APIKey, AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse } from '../api/client'; import { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card'; import { SlicerBundlesPanel } from '../components/SlicerBundlesPanel'; import { CameraTokensSection } from './CameraTokensPage'; import { Collapsible } from '../components/Collapsible'; import { Button } from '../components/Button'; import { SmartPlugCard } from '../components/SmartPlugCard'; import { AddSmartPlugModal } from '../components/AddSmartPlugModal'; 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 { 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 { Toggle } from '../components/Toggle'; 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 } from 'react'; import { Palette } from 'lucide-react'; import { registerSettingsSearch, getSettingsSearchEntries } from '../lib/settingsSearch'; import type { UsersSubTab } from '../lib/settingsSearch'; const validTabs = ['general', 'plugs', '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: '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.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.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.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 Links (external links settings is rendered in the General tab) registerSettingsSearch({ labelKey: 'externalLinks.title', labelFallback: 'Sidebar Links', tab: 'general', keywords: 'sidebar links 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' }); 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]; 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, darkStyle, darkBackground, darkAccent, lightStyle, lightBackground, lightAccent, setDarkStyle, setDarkBackground, setDarkAccent, setLightStyle, setLightBackground, setLightAccent, } = useTheme(); const [localSettings, setLocalSettings] = useState(null); const [showPlugModal, setShowPlugModal] = useState(false); const [editingPlug, setEditingPlug] = useState(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 [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'); // Update URL when tab changes const handleTabChange = (tab: TabType) => { setActiveTab(tab); if (tab === 'users') { setUsersSubTab('users'); } if (tab === 'general') { searchParams.delete('tab'); } else { searchParams.set('tab', tab); } 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_access_cloud: false, can_update_energy_cost: false, }); const [createdAPIKey, setCreatedAPIKey] = useState(null); 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 handleResetSidebarOrder = () => { localStorage.removeItem('sidebarOrder'); window.location.reload(); }; const isDefaultSidebarEnabled = !!localSettings?.default_sidebar_order; const handleToggleDefaultSidebarOrder = async (enabled: boolean) => { try { if (enabled) { let orderArr: string[]; const stored = localStorage.getItem('sidebarOrder'); try { orderArr = stored ? JSON.parse(stored) : defaultNavItems.map(i => i.id); } catch { orderArr = defaultNavItems.map(i => i.id); } if (!Array.isArray(orderArr) || orderArr.length === 0) { orderArr = defaultNavItems.map(i => i.id); } const payload = JSON.stringify({ order: orderArr }); await api.updateSettings({ default_sidebar_order: payload }); setLocalSettings(prev => prev ? { ...prev, default_sidebar_order: payload } : prev); showToast(t('settings.sidebarDefaultSet'), 'success'); } else { await api.updateSettings({ default_sidebar_order: '' }); setLocalSettings(prev => prev ? { ...prev, default_sidebar_order: '' } : prev); showToast(t('settings.sidebarDefaultCleared'), 'success'); } queryClient.invalidateQueries({ queryKey: ['settings'] }); queryClient.invalidateQueries({ queryKey: ['default-sidebar-order'] }); } catch { showToast(t('settings.sidebarDefaultFailed'), 'error'); } }; 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); const isReachable = (status?.reachable || hasMqttData) && status?.energy; 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_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: 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 } } = 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) { 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); // 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, }; setLocalSettings(settingsWithExternalUrl); // Mark initial load complete after a short delay setTimeout(() => { isInitialLoadRef.current = false; }, 100); } }, [settings, localSettings]); const updateMutation = useMutation({ mutationFn: api.updateSettings, onSuccess: (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 if (isInitialLoadRef.current || !localSettings || !settings) { 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 = settings.auto_archive !== localSettings.auto_archive || settings.save_thumbnails !== localSettings.save_thumbnails || settings.capture_finish_photo !== localSettings.capture_finish_photo || settings.default_filament_cost !== localSettings.default_filament_cost || settings.currency !== localSettings.currency || settings.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh || settings.energy_tracking_mode !== localSettings.energy_tracking_mode || settings.check_updates !== localSettings.check_updates || (settings.check_printer_firmware ?? true) !== (localSettings.check_printer_firmware ?? true) || (settings.include_beta_updates ?? false) !== (localSettings.include_beta_updates ?? false) || settings.notification_language !== localSettings.notification_language || (settings.bed_cooled_threshold ?? 35) !== (localSettings.bed_cooled_threshold ?? 35) || settings.ams_humidity_good !== localSettings.ams_humidity_good || settings.ams_humidity_fair !== localSettings.ams_humidity_fair || settings.ams_temp_good !== localSettings.ams_temp_good || settings.ams_temp_fair !== localSettings.ams_temp_fair || settings.ams_history_retention_days !== localSettings.ams_history_retention_days || settings.disable_filament_warnings !== localSettings.disable_filament_warnings || settings.prefer_lowest_filament !== localSettings.prefer_lowest_filament || (settings.queue_drying_enabled ?? false) !== (localSettings.queue_drying_enabled ?? false) || (settings.queue_drying_block ?? false) !== (localSettings.queue_drying_block ?? false) || (settings.ambient_drying_enabled ?? false) !== (localSettings.ambient_drying_enabled ?? false) || (settings.drying_presets ?? '') !== (localSettings.drying_presets ?? '') || settings.per_printer_mapping_expanded !== localSettings.per_printer_mapping_expanded || settings.date_format !== localSettings.date_format || settings.time_format !== localSettings.time_format || settings.default_printer_id !== localSettings.default_printer_id || settings.ftp_retry_enabled !== localSettings.ftp_retry_enabled || settings.ftp_retry_count !== localSettings.ftp_retry_count || settings.ftp_retry_delay !== localSettings.ftp_retry_delay || settings.ftp_timeout !== localSettings.ftp_timeout || settings.mqtt_enabled !== localSettings.mqtt_enabled || settings.mqtt_broker !== localSettings.mqtt_broker || settings.mqtt_port !== localSettings.mqtt_port || settings.mqtt_username !== localSettings.mqtt_username || settings.mqtt_password !== localSettings.mqtt_password || settings.mqtt_topic_prefix !== localSettings.mqtt_topic_prefix || settings.mqtt_use_tls !== localSettings.mqtt_use_tls || settings.external_url !== localSettings.external_url || settings.ha_enabled !== localSettings.ha_enabled || settings.ha_url !== localSettings.ha_url || settings.ha_token !== localSettings.ha_token || (settings.library_archive_mode ?? 'ask') !== (localSettings.library_archive_mode ?? 'ask') || Number(settings.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5) || (settings.camera_view_mode ?? 'window') !== (localSettings.camera_view_mode ?? 'window') || (settings.preferred_slicer ?? 'bambu_studio') !== (localSettings.preferred_slicer ?? 'bambu_studio') || (settings.use_slicer_api ?? false) !== (localSettings.use_slicer_api ?? false) || (settings.orcaslicer_api_url ?? '') !== (localSettings.orcaslicer_api_url ?? '') || (settings.bambu_studio_api_url ?? '') !== (localSettings.bambu_studio_api_url ?? '') || settings.prometheus_enabled !== localSettings.prometheus_enabled || settings.prometheus_token !== localSettings.prometheus_token || (settings.user_notifications_enabled ?? true) !== (localSettings.user_notifications_enabled ?? true) || (settings.default_bed_levelling ?? true) !== (localSettings.default_bed_levelling ?? true) || (settings.default_flow_cali ?? false) !== (localSettings.default_flow_cali ?? false) || (settings.default_vibration_cali ?? true) !== (localSettings.default_vibration_cali ?? true) || (settings.default_layer_inspect ?? false) !== (localSettings.default_layer_inspect ?? false) || (settings.default_timelapse ?? false) !== (localSettings.default_timelapse ?? false) || (settings.stagger_group_size ?? 2) !== (localSettings.stagger_group_size ?? 2) || (settings.stagger_interval_minutes ?? 5) !== (localSettings.stagger_interval_minutes ?? 5) || (settings.require_plate_clear ?? false) !== (localSettings.require_plate_clear ?? false); 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, 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, 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_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, drying_presets: localSettings.drying_presets, 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_view_mode: localSettings.camera_view_mode, preferred_slicer: localSettings.preferred_slicer, use_slicer_api: localSettings.use_slicer_api, orcaslicer_api_url: localSettings.orcaslicer_api_url, 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, stagger_group_size: localSettings.stagger_group_size, stagger_interval_minutes: localSettings.stagger_interval_minutes, require_plate_clear: localSettings.require_plate_clear, }; 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) { showToast(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); }; 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' && (
{/* Left Column - General Settings */}

{t('settings.general')}

{t('settings.languageDescription')}

{t('settings.defaultViewDescription')}

{t('settings.defaultPrinterDescription')}

{t('settings.sidebarOrder')}

{t('settings.sidebarOrderDescription')} {authEnabled && hasPermission('settings:update') && ` ${t('settings.sidebarOrderSetDefaultHint')}`}

{authEnabled && hasPermission('settings:update') && (
{t('settings.setDefault')}
)}

{t('settings.appearance')}

{/* Dark Mode Settings */}

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

{/* Light Mode Settings */}

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

{t('settings.themeToggleHint')}

{t('settings.archiveSettings')}

{t('settings.autoArchivePrints')}

{t('settings.autoArchiveDescription')}

{t('settings.saveThumbnails')}

{t('settings.saveThumbnailsDescription')}

{t('settings.captureFinishPhoto')}

{t('settings.captureFinishPhotoDescription')}

{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 */}

{localSettings.camera_view_mode === 'embedded' ? t('settings.cameraOverlayDescription') : t('settings.cameraWindowDescription')}

{/* 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')}

{/* File Manager Settings */}

{t('settings.fileManager')}

{/* Archive Mode */}

{t('settings.createArchiveEntryDescription')}

{/* Disk Space Warning Threshold */}
updateSetting('library_disk_warning_gb', parseFloat(e.target.value) || 5)} 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" /> GB

{t('settings.lowDiskSpaceDescription')}

{/* Auto-purge (#1008). Admin-only — users without library:purge don't see this section since they can't trigger a bulk purge even manually. */} {canPurge && trashSettings && (

{t('libraryAutoPurge.enableLabel')}

{t('libraryAutoPurge.enableDescription')}

saveTrashSettings({ auto_purge_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('libraryAutoPurge.days')}

{t('libraryAutoPurge.ageDescription')}

)}
{/* Third Column - Sidebar Links & Updates */}
{/* Sidebar Links */}

{t('settings.updates')}

{t('settings.printerFirmware')}

{t('settings.checkPrinterFirmware')}

{t('settings.checkFirmwareDescription')}

{t('settings.bambuddySoftware')}

{t('settings.checkForUpdatesLabel')}

{t('settings.autoCheckDescription')}

{t('settings.includeBetaUpdates')}

{t('settings.includeBetaUpdatesDesc')}

{t('settings.currentVersion')}

v{versionInfo?.version || '...'}

{updateCheck?.update_available ? (

Update available: v{updateCheck.latest_version}

{updateCheck.release_name && updateCheck.release_name !== updateCheck.latest_version && (

{updateCheck.release_name}

)}
{updateCheck.release_notes && ( )} {updateCheck.release_url && ( )}
{updateStatus?.status === 'downloading' || updateStatus?.status === 'installing' ? (
{updateStatus.message}
) : updateStatus?.status === 'complete' ? (
{updateStatus.message}
) : updateStatus?.status === 'error' ? (
{updateStatus.error || updateStatus.message}
) : updateCheck?.is_ha_addon ? (

{t('settings.updateViaHomeAssistant')}

) : updateCheck?.is_docker ? (

{t('settings.updateViaDocker')}

docker compose pull && docker compose up -d
) : ( )}
) : updateCheck?.error ? (
{t('settings.failedToCheckUpdates', { error: updateCheck.error })}
) : updateCheck && !updateCheck.update_available ? (

{t('settings.latestVersionRunning')}

) : null}
{/* Data Management */}

{t('settings.dataManagement')}

{t('settings.clearNotificationLogs')}

{t('settings.clearNotificationLogsDescription')}

{t('settings.resetUiPreferences')}

{t('settings.resetUiPreferencesDescription')}

{t('settings.storageUsage', 'Storage Usage')}

{t('settings.storageUsageDescription', 'Breakdown of data usage by category')}

{storageUsageLoading ? (
{t('common.loading', 'Loading')}
) : storageUsage ? ( <>
{storageUsage.categories .filter((category) => category.bytes > 0) .map((category, index) => (
))}
{storageUsage.categories .filter((category) => category.bytes > 0) .map((category, index) => (
{category.label} {category.formatted} ({category.percent_of_total.toFixed(1)}%)
))}
{t('settings.storageUsageTotal', 'Total')}: {storageUsage.total_formatted} {storageUsage.scan_errors > 0 && ( {t('settings.storageUsageErrors', 'Scan errors')}: {storageUsage.scan_errors} )}
{storageUsage.other_breakdown?.length > 0 && (

{t('settings.storageUsageOtherBreakdown', 'Other breakdown')}

{storageUsage.other_breakdown.map((item) => (
{item.label} {item.kind === 'system' ? t('settings.storageUsageSystem', 'System') : t('settings.storageUsageData', 'Data')}
{item.formatted} ({item.percent_of_total.toFixed(1)}%)
))}
)} ) : (

{t('settings.storageUsageUnavailable', 'Storage usage data is unavailable')}

)}

{t('settings.backupRestore')}

{t('settings.backupRestoreDescription')}

)} {/* Network Tab */} {activeTab === 'network' && localSettings && (
{/* Left Column - External URL & FTP Retry */}
{/* External URL */}

{t('settings.externalUrl')}

{t('settings.externalUrlDescription')}

updateSetting('external_url', e.target.value)} placeholder="http://192.168.1.100:8000" className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" />

{t('settings.externalUrlHint')}

{t('settings.ftpRetry')}

{t('settings.ftpRetryDescription')}

{t('settings.enableRetry')}

{t('settings.autoRetryDescription')}

{localSettings.ftp_retry_enabled && (

{t('settings.increaseForWeakWifi')}

)}
{/* Right Column - Home Assistant & MQTT Publishing */}
{/* Home Assistant Integration */}

{t('settings.homeAssistant')}

{localSettings.ha_enabled && haTestResult && (
{haTestResult.success ? t('settings.connected') : t('settings.disconnected')}
)}

{t('settings.homeAssistantFullDescription')}

{t('settings.enableHomeAssistant')}

{t('settings.homeAssistantDescription')}

{localSettings.ha_env_managed && (
{t('settings.autoEnabledViaEnv')}
)}
{localSettings.ha_enabled && ( <>
updateSetting('ha_url', e.target.value)} placeholder="http://192.168.1.100:8123" disabled={localSettings.ha_url_from_env} className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${ localSettings.ha_url_from_env ? 'opacity-60 cursor-not-allowed' : '' }`} /> {localSettings.ha_url_from_env && ( )}
{localSettings.ha_url_from_env && (

{t('settings.urlFromEnvReadOnly')}

)}
updateSetting('ha_token', e.target.value)} placeholder="eyJ0eXAiOiJKV1QiLC..." disabled={localSettings.ha_token_from_env} className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${ localSettings.ha_token_from_env ? 'opacity-60 cursor-not-allowed' : '' }`} /> {localSettings.ha_token_from_env && ( )}
{localSettings.ha_token_from_env ? (

{t('settings.tokenFromEnvReadOnly')}

) : (

{t('settings.haTokenHint')}

)}
{localSettings.ha_url && localSettings.ha_token && (
)} )}
{/* MQTT Publishing */}

{t('settings.mqttPublishing')}

{mqttStatus?.enabled && (
{mqttStatus.connected ? t('settings.connected') : t('settings.disconnected')}
)}

{t('settings.mqttDescription')}

{t('settings.enableMqtt')}

{t('settings.mqttEnableDescription')}

{localSettings.mqtt_enabled && (
updateSetting('mqtt_broker', e.target.value)} placeholder="mqtt.example.com or 192.168.1.100" className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" />
updateSetting('mqtt_port', Math.min(65535, Math.max(1, parseInt(e.target.value) || 1883)))} 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" />
{t('settings.useTls')}
updateSetting('mqtt_username', e.target.value)} placeholder={t('settings.leaveEmptyForAnonymous')} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" />
updateSetting('mqtt_password', e.target.value)} placeholder={t('settings.leaveEmptyForAnonymous')} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" />
updateSetting('mqtt_topic_prefix', e.target.value)} placeholder="bambuddy" className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" />

{t('settings.topicPrefixHint', { prefix: localSettings.mqtt_topic_prefix || 'bambuddy' })}

{/* Connection Info */} {mqttStatus && (
{mqttStatus.connected ? ( <>{t('settings.mqttConnectedTo')} {mqttStatus.broker}:{mqttStatus.port} ) : ( t('settings.spoolmanDisconnected') )}
)}
)}
{/* Third Column - Prometheus Metrics */}

{t('settings.prometheusMetrics')}

{t('settings.prometheusEndpointDescription')}

{t('settings.enableMetricsEndpoint')}

{t('settings.prometheusDescription')}

{localSettings.prometheus_enabled && (
updateSetting('prometheus_token', e.target.value)} placeholder={t('settings.leaveEmptyForNoAuth')} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" />

{t('settings.bearerTokenHint')}

{t('settings.availableMetrics')}

bambuddy_printer_connected - {t('settings.metricsConnectionStatus')}

bambuddy_printer_state - {t('settings.metricsPrinterState')}

bambuddy_print_progress - {t('settings.metricsPrintProgress')}

bambuddy_bed_temp_celsius - {t('settings.metricsBedTemp')}

bambuddy_nozzle_temp_celsius - {t('settings.metricsNozzleTemp')}

bambuddy_prints_total - {t('settings.metricsPrintsTotal')}

{t('settings.metricsMore')}

)}
)} {/* Home Assistant Test Connection Modal */} {haTestResult && (
{haTestResult.success ? ( ) : ( )}

{haTestResult.success ? t('settings.connectionSuccessful') : t('settings.connectionFailed')}

{haTestResult.success ? haTestResult.message || t('settings.haConnectionSuccess') : haTestResult.error || t('settings.haConnectionFailed')}

)} {/* Smart Plugs Tab */} {activeTab === 'plugs' && (

{t('settings.smartPlugs')}

{t('settings.smartPlugsDescription')}

{smartPlugs && smartPlugs.filter(p => p.enabled).length > 1 && ( <> )}
{/* Energy Summary Card */} {smartPlugs && smartPlugs.length > 0 && (

{t('settings.energySummary')} {energyLoading && ( )}

{plugEnergySummary ? (
{/* Current Power */}
{t('settings.currentPower')}
{plugEnergySummary.totalPower.toFixed(1)} W
{t('settings.plugsOnline', { reachable: plugEnergySummary.reachableCount, total: plugEnergySummary.totalPlugs })}
{/* Today */}
{t('settings.today')}
{plugEnergySummary.totalToday.toFixed(3)} kWh
{(localSettings?.energy_cost_per_kwh ?? 0) > 0 && (
~{(plugEnergySummary.totalToday * (localSettings?.energy_cost_per_kwh ?? 0)).toFixed(2)} {getCurrencySymbol(localSettings?.currency || 'USD')}
)}
{/* Yesterday */}
{t('settings.yesterday')}
{plugEnergySummary.totalYesterday.toFixed(3)} kWh
{(localSettings?.energy_cost_per_kwh ?? 0) > 0 && (
~{(plugEnergySummary.totalYesterday * (localSettings?.energy_cost_per_kwh ?? 0)).toFixed(2)} {getCurrencySymbol(localSettings?.currency || 'USD')}
)}
{/* Total Lifetime */}
{t('settings.total')}
{plugEnergySummary.totalLifetime.toFixed(1)} kWh
{(localSettings?.energy_cost_per_kwh ?? 0) > 0 && (
~{(plugEnergySummary.totalLifetime * (localSettings?.energy_cost_per_kwh ?? 0)).toFixed(2)} {getCurrencySymbol(localSettings?.currency || 'USD')}
)}
) : !energyLoading ? (

{t('settings.enablePlugsForSummary')}

) : null}
)} {plugsLoading ? (
) : smartPlugs && smartPlugs.length > 0 ? (
{smartPlugs.map((plug) => ( { setEditingPlug(p); setShowPlugModal(true); }} /> ))}
) : (

{t('settings.noSmartPlugsTitle')}

{t('settings.noSmartPlugsDescription')}

)}
)} {/* Notifications Tab */} {activeTab === 'notifications' && (
{/* Left Column: Providers */}

{t('settings.providers')}

{notificationProviders && notificationProviders.length > 0 && ( )}
{/* Notification Language Setting */}

{t('settings.notificationLanguage')}

{t('settings.notificationLanguageDescription')}

{/* Bed Cooled Threshold Setting */}

{t('settings.bedCooledThreshold')}

{t('settings.bedCooledThresholdDescription')}

updateSetting('bed_cooled_threshold', Number(e.target.value))} className="w-16 px-2 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm text-center focus:outline-none focus:ring-1 focus:ring-bambu-green" /> °C
{/* User Notifications Toggle */}

{t('settings.userNotificationsEnabled')}

{!advancedAuthStatus?.advanced_auth_enabled ? t('settings.userNotificationsDisabledHint') : t('settings.userNotificationsEnabledDescription')}

{/* Test All Results */} {testAllResult && (
{t('settings.testResults')}
{t('settings.testPassedCount', { count: testAllResult.success })} {testAllResult.failed > 0 && ( {t('settings.testFailedCount', { count: testAllResult.failed })} )}
{testAllResult.results.filter(r => !r.success).length > 0 && (
{testAllResult.results.filter(r => !r.success).map((result) => (
{result.provider_name}: {result.message}
))}
)}
)} {providersLoading ? (
) : notificationProviders && notificationProviders.length > 0 ? (
{notificationProviders.map((provider) => ( { setEditingProvider(p); setShowNotificationModal(true); }} /> ))}
) : (

{t('settings.noProvidersTitle')}

{t('settings.noProvidersDescription')}

)}
{/* Right Column: Templates */}

{t('settings.messageTemplates')}

{t('settings.messageTemplatesDescription')}

{/* Filter input */}
setTemplateFilter(e.target.value)} placeholder={t('settings.filterTemplates', 'Filter templates…')} 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" /> {templateFilter && ( )}
{templatesLoading ? (
) : notificationTemplates && notificationTemplates.length > 0 ? ( (() => { const filter = templateFilter.trim().toLowerCase(); const filtered = [...notificationTemplates] .sort((a, b) => a.name.localeCompare(b.name)) .filter(tpl => !filter || tpl.name.toLowerCase().includes(filter) || (tpl.title_template || '').toLowerCase().includes(filter) ); if (filtered.length === 0) { return (

{t('settings.noTemplatesMatch', 'No templates match your filter.')}

); } return (
{filtered.map((template) => ( setEditingTemplate(template)} >

{template.name}

{template.title_template}

))}
); })() ) : (

{t('settings.noTemplatesAvailable')}

)}
)} {/* API Keys Tab */} {activeTab === 'apikeys' && (
{/* Left Column - API Keys Management. Admin-gated content (webhook keys, webhook docs) is hidden from users without api_keys:read; the Camera Tokens panel is always shown so users with camera:view can self-manage their own tokens. */}
{hasPermission('api_keys:read') && <>

{t('settings.apiKeys')}

{t('settings.apiKeysDescription')}

{/* Created Key Display */} {createdAPIKey && (

{t('settings.apiKeyCreated')}

{t('settings.apiKeyCopyWarning')}

{createdAPIKey}
)} {/* Create Key Form */} {showCreateAPIKey && (

{t('settings.createNewApiKey')}

setNewAPIKeyName(e.target.value)} placeholder={t('settings.keyNamePlaceholder')} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" />
)} {/* Existing Keys List */} {apiKeysLoading ? (
) : apiKeys && apiKeys.length > 0 ? (
{apiKeys.map((key) => (

{key.name}

{key.key_prefix}•••••••• {key.last_used && ` · ${t('settings.lastUsed')}: ${formatDateOnly(key.last_used)}`}

{key.can_read_status && ( {t('settings.read')} )} {key.can_queue && ( {t('queue.title')} )} {key.can_control_printer && ( {t('settings.control')} )} {key.can_access_cloud && ( {t('settings.cloudBadge', 'Cloud')} )} {key.can_update_energy_cost && ( {t('settings.energyCostBadge')} )} {key.user_id === null && ( {t('settings.legacyKey', 'Legacy')} )}
))}
) : (

{t('settings.apiKeysEmptyTitle')}

{t('settings.apiKeysEmptyDescription')}

)} {/* Webhook Documentation */}

{t('settings.webhookEndpoints')}

{t('settings.webhookApiKeyHint')}

GET{' '} /api/v1/webhook/status - {t('settings.webhook.getAllStatus')}
GET{' '} /api/v1/webhook/status/:id - {t('settings.webhook.getSpecificStatus')}
POST{' '} /api/v1/webhook/queue - {t('settings.webhook.addToQueue')}
POST{' '} /api/v1/webhook/printer/:id/pause - {t('settings.webhook.pausePrint')}
POST{' '} /api/v1/webhook/printer/:id/resume - {t('settings.webhook.resumePrint')}
POST{' '} /api/v1/webhook/printer/:id/stop - {t('settings.webhook.stopPrint')}
} {/* Long-lived camera-stream tokens (#1108) */}

{/* Right Column - API Browser. Hidden from users without api_keys:read since the API Browser is the testing surface for those keys; non-admins land in this tab only for the Camera Tokens panel and don't need the browser. */} {hasPermission('api_keys:read') &&

{t('settings.apiBrowser')}

{t('settings.apiBrowserDescription')}

{/* API Key Input for Testing */} setTestApiKey(e.target.value)} placeholder={t('settings.apiKeyPlaceholder')} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white font-mono text-sm focus:border-bambu-green focus:outline-none" />

{t('settings.apiKeyHint')}

}
)} {/* Virtual Printer Tab */} {activeTab === 'virtual-printer' && (
)} {/* SpoolBuddy Tab */} {activeTab === 'spoolbuddy' && (
)} {/* Filament Tab */} {/* Queue Tab */} {activeTab === 'queue' && localSettings && (
{/* Left Column */}
{/* Default Print Options */}

{t('settings.defaultPrintOptions', 'Default Print Options')}

{t('settings.defaultPrintOptionsDescription', 'Set default values for print options when starting new prints. These can be overridden per print in the print dialog.')}

{[ { key: 'default_bed_levelling' as const, label: t('settings.defaultBedLevelling', 'Bed Levelling'), desc: t('settings.defaultBedLevellingDesc', 'Auto-level bed before print'), fallback: true }, { key: 'default_flow_cali' as const, label: t('settings.defaultFlowCali', 'Flow Calibration'), desc: t('settings.defaultFlowCaliDesc', 'Calibrate extrusion flow'), fallback: false }, { key: 'default_vibration_cali' as const, label: t('settings.defaultVibrationCali', 'Vibration Calibration'), desc: t('settings.defaultVibrationCaliDesc', 'Reduce ringing artifacts'), fallback: true }, { key: 'default_layer_inspect' as const, label: t('settings.defaultLayerInspect', 'First Layer Inspection'), desc: t('settings.defaultLayerInspectDesc', 'AI inspection of first layer'), fallback: false }, { key: 'default_timelapse' as const, label: t('settings.defaultTimelapse', 'Timelapse'), desc: t('settings.defaultTimelapseDesc', 'Record timelapse video'), fallback: false }, ].map(({ key, label, desc, fallback }) => (

{label}

{desc}

))}
{/* Staggered Batch Start */}

{t('settings.staggeredStart', 'Staggered Start')}

{t('settings.staggeredStartDescription', 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.')}

updateSetting('stagger_group_size', Math.max(1, Math.min(50, parseInt(e.target.value) || 1)))} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green" />

{t('settings.staggerGroupSizeHelp', 'Printers to start simultaneously per group')}

updateSetting('stagger_interval_minutes', Math.max(1, Math.min(60, parseInt(e.target.value) || 1)))} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green" />

{t('settings.staggerIntervalHelp', 'Delay between each group starting')}

{/* Plate-Clear Confirmation */}

{t('settings.plateClear', 'Plate-Clear Confirmation')}

{t('settings.requirePlateClear', 'Require plate-clear confirmation')}

{t('settings.requirePlateClearDescription', 'When enabled, the scheduler waits for per-printer plate-clear confirmation before starting queued prints on printers with finished jobs. Disabling this also hides the plate status badge and the "Mark plate as cleared" button on printer cards.')}

{/* G-code Injection (#422) */}

{t('settings.gcodeInjection', 'G-code Injection')}

{t('settings.gcodeInjectionDescription', 'Configure custom G-code to inject at the start and/or end of prints for auto-print systems like Farmloop, SwapMod, AutoClear, and Printflow 3D. Snippets are configured per printer model and applied when "Inject G-code" is enabled on a queue item.')}

{(() => { const gcodeSnippets: Record = (() => { try { return localSettings.gcode_snippets ? JSON.parse(localSettings.gcode_snippets) : {}; } catch { return {}; } })(); const printerModels = [...new Set((printers || []).filter((p) => p.model).map((p) => p.model as string))].sort(); const updateSnippet = (model: string, field: 'start_gcode' | 'end_gcode', value: string) => { const updated = { ...gcodeSnippets }; if (!updated[model]) { updated[model] = { start_gcode: '', end_gcode: '' }; } updated[model][field] = value; // Remove model entry if both fields are empty if (!updated[model].start_gcode && !updated[model].end_gcode) { delete updated[model]; } const newValue = Object.keys(updated).length > 0 ? JSON.stringify(updated) : ''; // Update local state for immediate UI feedback, save on blur setLocalSettings(prev => prev ? { ...prev, gcode_snippets: newValue } : null); pendingGcodeSnippetsRef.current = newValue; }; const saveGcodeSnippets = () => { if (pendingGcodeSnippetsRef.current !== null) { updateMutation.mutate({ gcode_snippets: pendingGcodeSnippetsRef.current }); pendingGcodeSnippetsRef.current = null; } }; if (printerModels.length === 0) { return (

{t('settings.gcodeInjectionNoPrinters', 'No printers found. Add printers to configure G-code snippets.')}

); } return printerModels.map((model) => { const snippet = gcodeSnippets[model] || { start_gcode: '', end_gcode: '' }; const hasContent = !!(snippet.start_gcode || snippet.end_gcode); return (

{model}

{hasContent && ( {t('settings.gcodeConfigured', 'Configured')} )}
} >