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, ChevronRight, Check, Save, Mail } 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 type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, Group, GroupCreate, GroupUpdate, Permission, PermissionCategory } from '../api/client'; import { Card, CardContent, CardHeader } from '../components/Card'; 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 { SpoolmanSettings } from '../components/SpoolmanSettings'; import { ExternalLinksSettings } from '../components/ExternalLinksSettings'; import { VirtualPrinterSettings } from '../components/VirtualPrinterSettings'; import { GitHubBackupSettings } from '../components/GitHubBackupSettings'; import { EmailSettings } from '../components/EmailSettings'; import { APIBrowser } from '../components/APIBrowser'; import { virtualPrinterApi } 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'; const validTabs = ['general', 'network', 'plugs', 'email', 'notifications', 'filament', 'apikeys', 'virtual-printer', 'users', 'backup'] as const; type TabType = typeof validTabs[number]; export function SettingsPage() { const queryClient = useQueryClient(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const { t, i18n } = useTranslation(); const { showToast } = useToast(); const { authEnabled, user, refreshAuth } = 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 [showLogViewer, setShowLogViewer] = useState(false); const [defaultView, setDefaultViewState] = useState(getDefaultView()); // Initialize tab from URL params const tabParam = searchParams.get('tab'); const initialTab = tabParam && validTabs.includes(tabParam as TabType) ? tabParam as TabType : 'general'; const [activeTab, setActiveTab] = useState(initialTab); // Update URL when tab changes const handleTabChange = (tab: TabType) => { setActiveTab(tab); 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, }); 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); // User management state const [showCreateUserModal, setShowCreateUserModal] = useState(false); 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 [showCreateGroupModal, setShowCreateGroupModal] = useState(false); const [editingGroup, setEditingGroup] = useState(null); const [deleteGroupId, setDeleteGroupId] = useState(null); const [groupFormData, setGroupFormData] = useState<{ name: string; description: string; permissions: Permission[]; }>({ name: '', description: '', permissions: [], }); const [expandedCategories, setExpandedCategories] = useState>(new Set()); // 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 { data: settings, isLoading } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings, }); 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 }) => 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: () => { queryClient.invalidateQueries({ queryKey: ['api-keys'] }); 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; const { data: ffmpegStatus } = useQuery({ queryKey: ['ffmpeg-status'], queryFn: api.checkFfmpeg, }); const { data: versionInfo } = useQuery({ queryKey: ['version'], queryFn: api.getVersion, }); const { data: updateCheck, refetch: refetchUpdateCheck, isRefetching: isCheckingUpdate } = useQuery({ queryKey: ['updateCheck'], queryFn: api.checkForUpdates, 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(), }); // User management queries and mutations const { hasPermission } = useAuth(); 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 { data: permissionsData } = useQuery({ queryKey: ['permissions'], queryFn: () => api.getPermissions(), 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 createGroupMutation = useMutation({ mutationFn: (data: GroupCreate) => api.createGroup(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['groups'] }); setShowCreateGroupModal(false); resetGroupForm(); showToast(t('settings.toast.groupCreated')); }, onError: (error: Error) => { showToast(error.message, 'error'); }, }); const updateGroupMutation = useMutation({ mutationFn: ({ id, data }: { id: number; data: GroupUpdate }) => api.updateGroup(id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['groups'] }); setEditingGroup(null); resetGroupForm(); showToast(t('settings.toast.groupUpdated')); }, onError: (error: Error) => { showToast(error.message, 'error'); }, }); 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; } if (userFormData.password.length < 6) { showToast(t('settings.toast.passwordTooShort'), '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; } if (userFormData.password.length < 6) { showToast(t('settings.toast.passwordTooShort'), 'error'); return; } } const updateData: UserUpdate = { username: userFormData.username || undefined, password: userFormData.password || 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], })); }; // Group management handlers const resetGroupForm = () => { setGroupFormData({ name: '', description: '', permissions: [] }); setExpandedCategories(new Set()); }; const handleCreateGroup = () => { if (!groupFormData.name.trim()) { showToast(t('settings.toast.enterGroupName'), 'error'); return; } createGroupMutation.mutate({ name: groupFormData.name, description: groupFormData.description || undefined, permissions: groupFormData.permissions, }); }; const handleUpdateGroup = () => { if (!editingGroup) return; if (!groupFormData.name.trim()) { showToast(t('settings.toast.enterGroupName'), 'error'); return; } updateGroupMutation.mutate({ id: editingGroup.id, data: { name: groupFormData.name !== editingGroup.name ? groupFormData.name : undefined, description: groupFormData.description, permissions: groupFormData.permissions, }, }); }; const startEditGroup = (group: Group) => { setEditingGroup(group); setGroupFormData({ name: group.name, description: group.description || '', permissions: group.permissions, }); const cats = new Set(); permissionsData?.categories.forEach((cat) => { if (cat.permissions.some((p) => group.permissions.includes(p.value))) { cats.add(cat.name); } }); setExpandedCategories(cats); }; const toggleCategory = (categoryName: string) => { setExpandedCategories((prev) => { const next = new Set(prev); if (next.has(categoryName)) { next.delete(categoryName); } else { next.add(categoryName); } return next; }); }; const togglePermission = (permission: Permission) => { setGroupFormData((prev) => { const permissions = prev.permissions.includes(permission) ? prev.permissions.filter((p) => p !== permission) : [...prev.permissions, permission]; return { ...prev, permissions }; }); }; const toggleCategoryPermissions = (category: PermissionCategory, checked: boolean) => { setGroupFormData((prev) => { const categoryPerms = category.permissions.map((p) => p.value); const otherPerms = prev.permissions.filter((p) => !categoryPerms.includes(p)); const permissions = checked ? [...otherPerms, ...categoryPerms] : otherPerms; return { ...prev, permissions }; }); }; const isCategoryFullySelected = (category: PermissionCategory) => { return category.permissions.every((p) => groupFormData.permissions.includes(p.value)); }; const isCategoryPartiallySelected = (category: PermissionCategory) => { const selected = category.permissions.filter((p) => groupFormData.permissions.includes(p.value)); return selected.length > 0 && selected.length < category.permissions.length; }; const applyUpdateMutation = useMutation({ mutationFn: api.applyUpdate, onSuccess: (data) => { if (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 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); // Sync localSettings with the saved data to prevent re-triggering saves setLocalSettings(data); // Invalidate archive stats to reflect energy tracking mode change queryClient.invalidateQueries({ queryKey: ['archiveStats'] }); showToast(t('settings.toast.settingsSaved'), 'success'); }, onError: (error: Error) => { showToast(`Failed to save: ${error.message}`, 'error'); }, 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 }> }) => 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; } // 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.notification_language !== localSettings.notification_language || 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.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.prometheus_enabled !== localSettings.prometheus_enabled || settings.prometheus_token !== localSettings.prometheus_token; 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, notification_language: localSettings.notification_language, 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, 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, prometheus_enabled: localSettings.prometheus_enabled, prometheus_token: localSettings.prometheus_token, }; updateMutation.mutate(settingsToSave); }, 500); // Cleanup on unmount or when localSettings changes again return () => { if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); } }; }, [localSettings, settings, updateMutation]); const updateSetting = useCallback((key: K, value: AppSettings[K]) => { setLocalSettings(prev => prev ? { ...prev, [key]: value } : null); }, []); 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()); // Initialize local camera URLs from printer data useEffect(() => { if (printers) { const urls: 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 (Object.keys(urls).length > 0) { setLocalCameraUrls(prev => ({ ...prev, ...urls })); } } }, [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 handleUpdatePrinterCamera = (printerId: number, updates: { type?: string; enabled?: boolean }) => { const data: Partial<{ external_camera_type: string | null; external_camera_enabled: boolean }> = {}; if (updates.type !== undefined) data.external_camera_type = updates.type || null; if (updates.enabled !== undefined) data.external_camera_enabled = updates.enabled; updatePrinterMutation.mutate({ id: printerId, data }); }; if (isLoading || !localSettings) { return (
); } return (

{t('settings.title')}

{t('settings.configureBambuddy')}

{/* Tab Navigation */}
{/* General Tab */} {activeTab === 'general' && (
{/* Left Column - General Settings */}

{t('settings.general')}

{t('settings.languageDescription')}

{t('settings.defaultViewDescription')}

Pre-select this printer for uploads, reprints, and other operations.

{t('settings.preferredSlicerDescription')}

{t('settings.sidebarOrder')}

Drag items in the sidebar to reorder. Reset to default order here.

Appearance

{/* Dark Mode Settings */}

Dark Mode {mode === 'dark' && (active)}

{/* Light Mode Settings */}

Light Mode {mode === 'light' && (active)}

Toggle between dark and light mode using the sun/moon icon in the sidebar.

{t('settings.archiveSettings')}

Auto-archive prints

Automatically save 3MF files when prints complete

{t('settings.saveThumbnails')}

Extract and save preview images from 3MF files

{t('settings.captureFinishPhoto')}

Take a photo from printer camera when print completes

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

ffmpeg not installed

Camera capture requires ffmpeg. Install it via{' '} brew install ffmpeg (macOS) or{' '} apt install ffmpeg (Linux).

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

{localSettings.camera_view_mode === 'embedded' ? 'Camera opens in a resizable overlay on the main screen' : 'Camera opens in a separate browser window'}

{/* External Cameras Section */}

{t('settings.externalCameras')}

Configure external cameras to replace the built-in printer camera. Supports MJPEG streams, RTSP, HTTP snapshots, and USB cameras (V4L2). When enabled, the external camera is used for live view and finish photos.

{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 ? ( <> Connected{extCameraTestResults[printer.id]?.resolution && ` (${extCameraTestResults[printer.id]?.resolution})`} ) : ( <> {extCameraTestResults[printer.id]?.error || t('settings.toast.connectionFailed')} )}
)}
)}
))}
) : (

{t('settings.noPrintersConfigured')}

)}

{t('settings.costTracking')}

{getCurrencySymbol(localSettings.currency)} updateSetting('default_filament_cost', parseFloat(e.target.value) || 0) } className="w-full pl-8 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) } className="w-full pl-8 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' ? 'Dashboard shows sum of energy used during prints' : 'Dashboard shows lifetime energy from smart plugs'}

{/* File Manager Settings */}

File Manager

{/* Archive Mode */}

When printing from File Manager, optionally create an archive entry

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

Show warning when free disk space falls below this threshold

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

Updates

{t('settings.checkForUpdatesLabel')}

Automatically check for new versions on startup

{t('settings.checkPrinterFirmware')}

Check for printer firmware updates from Bambu Lab

{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_docker ? (

Update via Docker Compose:

docker compose pull && docker compose up -d
) : ( )}
) : updateCheck?.error ? (
Failed to check for updates: {updateCheck.error}
) : updateCheck && !updateCheck.update_available ? (

You're running the latest version

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

{t('settings.dataManagement')}

{t('settings.clearNotificationLogs')}

{t('settings.clearNotificationLogsDescription')}

{t('settings.resetUiPreferences')}

{t('settings.resetUiPreferencesDescription')}

Backup & Restore

Export/import settings and configure GitHub backup

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

External URL

The external URL where Bambuddy is accessible. Used for notification images and external integrations.

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" />

Include protocol and port (e.g., http://192.168.1.100:8000)

FTP Retry

Retry FTP operations when printer WiFi is unreliable. Applies to 3MF downloads, print uploads, timelapse downloads, and firmware updates.

{t('settings.enableRetry')}

Automatically retry failed FTP operations

{localSettings.ftp_retry_enabled && (

Increase for printers with weak WiFi

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

Home Assistant

{localSettings.ha_enabled && haTestResult && (
{haTestResult.success ? 'Connected' : 'Disconnected'}
)}

Connect to Home Assistant to control smart plugs via HA's REST API. Supports switch, light, input_boolean, and script entities.

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

) : (

Create a token in HA: Profile → Long-Lived Access Tokens → Create Token

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

MQTT Publishing

{mqttStatus?.enabled && (
{mqttStatus.connected ? 'Connected' : 'Disconnected'}
)}

Publish BamBuddy events to an external MQTT broker for integration with Node-RED, Home Assistant, and other automation systems.

{t('settings.enableMqtt')}

Publish events to external MQTT broker

{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" />

Topics will be: {localSettings.mqtt_topic_prefix || 'bambuddy'}/printers/<serial>/status, etc.

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

Prometheus Metrics

Expose printer metrics at /api/v1/metrics for Prometheus/Grafana monitoring.

{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" />

If set, requests must include Authorization: Bearer <token>

{t('settings.availableMetrics')}

bambuddy_printer_connected - Connection status

bambuddy_printer_state - Printer state (idle/printing/etc)

bambuddy_print_progress - Print progress 0-100%

bambuddy_bed_temp_celsius - Bed temperature

bambuddy_nozzle_temp_celsius - Nozzle temperature

bambuddy_prints_total - Total prints by result

...and more (layers, fans, queue, filament usage)

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

{haTestResult.success ? 'Connection Successful' : 'Connection Failed'}

{haTestResult.success ? haTestResult.message || 'Successfully connected to Home Assistant.' : haTestResult.error || 'Failed to connect to Home Assistant.'}

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

Smart Plugs

Connect smart plugs (Tasmota or Home Assistant) to automate power control and track energy usage for your printers.

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

Energy Summary {energyLoading && ( )}

{plugEnergySummary ? (
{/* Current Power */}
Current Power
{plugEnergySummary.totalPower.toFixed(1)} W
{plugEnergySummary.reachableCount}/{plugEnergySummary.totalPlugs} plugs online
{/* Today */}
Today
{plugEnergySummary.totalToday.toFixed(2)} kWh
{(localSettings?.energy_cost_per_kwh ?? 0) > 0 && (
~{(plugEnergySummary.totalToday * (localSettings?.energy_cost_per_kwh ?? 0)).toFixed(2)} {getCurrencySymbol(localSettings?.currency || 'USD')}
)}
{/* Yesterday */}
Yesterday
{plugEnergySummary.totalYesterday.toFixed(2)} kWh
{(localSettings?.energy_cost_per_kwh ?? 0) > 0 && (
~{(plugEnergySummary.totalYesterday * (localSettings?.energy_cost_per_kwh ?? 0)).toFixed(2)} {getCurrencySymbol(localSettings?.currency || 'USD')}
)}
{/* Total Lifetime */}
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 ? (

Enable plugs to see energy summary

) : 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')}

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

{templatesLoading ? (
) : notificationTemplates && notificationTemplates.length > 0 ? (
{notificationTemplates.map((template) => ( setEditingTemplate(template)} >

{template.name}

{template.title_template}

))}
) : (

{t('settings.noTemplatesAvailable')}

)}
)} {/* API Keys Tab */} {activeTab === 'apikeys' && (
{/* Left Column - API Keys Management */}

{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')} )}
))}
) : (

{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')}
{/* Right Column - API Browser */}

{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' && ( )} {/* Filament Tab */} {activeTab === 'filament' && localSettings && (
{/* Left Column - AMS Display Thresholds */}

{t('settings.amsDisplayThresholds')}

{t('settings.amsThresholdsDescription')}

{/* Humidity Thresholds */}
{t('settings.humidity')}
updateSetting('ams_humidity_good', parseInt(e.target.value) || 40)} 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('ams_humidity_fair', parseInt(e.target.value) || 60)} 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.aboveFairBad')}

{/* Temperature Thresholds */}
{t('settings.temperature')}
updateSetting('ams_temp_good', parseFloat(e.target.value) || 28)} 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" /> °C
updateSetting('ams_temp_fair', parseFloat(e.target.value) || 35)} 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" /> °C

{t('settings.aboveFairHot')}

{/* History Retention */}
{t('settings.historyRetention')}
updateSetting('ams_history_retention_days', parseInt(e.target.value) || 30)} 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('common.days')}

{t('settings.historyRetentionDescription')}

{/* Per-Printer Mapping Default */}
{t('settings.printModal')}

{t('settings.expandCustomMappingDescription')}

{/* Right Column - Spoolman Integration */}
)} {/* Delete API Key Confirmation */} {showDeleteAPIKeyConfirm !== null && ( { deleteAPIKeyMutation.mutate(showDeleteAPIKeyConfirm); setShowDeleteAPIKeyConfirm(null); }} onCancel={() => setShowDeleteAPIKeyConfirm(null)} /> )} {/* Smart Plug Modal */} {showPlugModal && ( { setShowPlugModal(false); setEditingPlug(null); }} /> )} {/* Notification Modal */} {showNotificationModal && ( { setShowNotificationModal(false); setEditingProvider(null); }} /> )} {/* Template Editor Modal */} {editingTemplate && ( setEditingTemplate(null)} /> )} {/* Notification Log Viewer */} {showLogViewer && ( setShowLogViewer(false)} /> )} {/* Confirm Modal: Clear Notification Logs */} {showClearLogsConfirm && ( { setShowClearLogsConfirm(false); try { const result = await api.clearNotificationLogs(30); showToast(result.message, 'success'); } catch { showToast(t('settings.toast.clearLogsFailed'), 'error'); } }} onCancel={() => setShowClearLogsConfirm(false)} /> )} {/* Confirm Modal: Clear Local Storage */} {showClearStorageConfirm && ( { setShowClearStorageConfirm(false); localStorage.clear(); showToast(t('settings.toast.uiPreferencesReset'), 'success'); setTimeout(() => window.location.reload(), 1000); }} onCancel={() => setShowClearStorageConfirm(false)} /> )} {/* Confirm Modal: Bulk Plug Action */} {showBulkPlugConfirm && ( p.enabled).length || 0} enabled smart plugs. ${showBulkPlugConfirm === 'off' ? 'Any running printers may be affected!' : ''}`} confirmText={`Turn All ${showBulkPlugConfirm === 'on' ? 'On' : 'Off'}`} variant={showBulkPlugConfirm === 'off' ? 'danger' : 'warning'} onConfirm={() => { const action = showBulkPlugConfirm; setShowBulkPlugConfirm(null); bulkPlugActionMutation.mutate(action); }} onCancel={() => setShowBulkPlugConfirm(null)} /> )} {/* Release Notes Modal */} {showReleaseNotes && updateCheck?.release_notes && (
setShowReleaseNotes(false)} > e.stopPropagation()}>

Release Notes - v{updateCheck.latest_version}

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

{updateCheck.release_name}

)}
                {updateCheck.release_notes}
              
{updateCheck.release_url && ( )}
)} {/* Users Tab */} {activeTab === 'users' && (
{/* Auth Toggle Header */}
{authEnabled ? ( ) : ( )}

{t('settings.authentication')}

{authEnabled ? t('settings.authEnabledDescription') : t('settings.authDisabledDescription')}

{!authEnabled ? ( ) : user?.is_admin && ( )}
{/* Advanced Authentication Notice Box */} {advancedAuthStatus?.advanced_auth_enabled && (

{t('settings.email.advancedAuthEnabled')}

{t('settings.email.advancedAuthEnabledDesc')}

)} {authEnabled && (
{/* Left Column: Current User + User List */}
{/* Current User Card */} {user && (

{t('settings.currentUser')}

{user.username}

{user.is_admin && ( {t('settings.admin')} )} {user.groups?.map(group => ( {group.name} ))}
)} {/* User List */}

{t('settings.users')}

{hasPermission('users:create') && ( )}
{usersLoading ? (
) : usersData.length === 0 ? (

{t('settings.noUsersFound')}

) : (
{usersData.map((userItem) => (

{userItem.username}

{userItem.is_admin && ( {t('settings.admin')} )} {userItem.groups?.map(group => ( {group.name} ))}
{hasPermission('users:update') && ( )} {hasPermission('users:delete') && userItem.id !== user?.id && ( )}
))}
)}
{/* Right Column: Groups */}

{t('settings.groups')}

{hasPermission('groups:create') && ( )}
{groupsLoading ? (
) : groupsData.length === 0 ? (

{t('settings.noGroupsFound')}

) : (
{groupsData.map((group) => (
{group.name} {group.is_system && ( {t('settings.system')} )}
{hasPermission('groups:update') && ( )} {hasPermission('groups:delete') && !group.is_system && ( )}

{group.description || t('settings.noDescription')}

{t('settings.userCount', { count: group.user_count })} {t('settings.permissionCount', { count: group.permissions.length })}
))}
)}
)} {/* Auth Disabled Info */} {!authEnabled && (

{t('settings.authDisabledTitle')}

{t('settings.authDisabledMessage')}

  • {t('settings.authDisabledFeature1')}
  • {t('settings.authDisabledFeature2')}
  • {t('settings.authDisabledFeature3')}
)}
)} {/* Create User Modal */} {showCreateUserModal && !advancedAuthStatus?.advanced_auth_enabled && (
{ setShowCreateUserModal(false); setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] }); }} > e.stopPropagation()} >

{t('settings.createUser')}

setUserFormData({ ...userFormData, username: e.target.value })} className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors" placeholder={t('settings.enterUsername')} autoComplete="username" />
setUserFormData({ ...userFormData, password: e.target.value })} className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors" placeholder={t('settings.enterPassword')} autoComplete="new-password" minLength={6} />
setUserFormData({ ...userFormData, confirmPassword: e.target.value })} className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${ userFormData.confirmPassword && userFormData.password !== userFormData.confirmPassword ? 'border-red-500' : 'border-bambu-dark-tertiary' }`} placeholder={t('settings.confirmPasswordPlaceholder')} autoComplete="new-password" minLength={6} /> {userFormData.confirmPassword && userFormData.password !== userFormData.confirmPassword && (

{t('settings.passwordsDoNotMatch')}

)}
{groupsData.map(group => ( ))} {groupsData.length === 0 && (

{t('settings.noGroupsAvailable')}

)}
)} {/* Create User Modal - Advanced Authentication */} {showCreateUserModal && advancedAuthStatus?.advanced_auth_enabled && ( { setShowCreateUserModal(false); setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] }); }} onCreate={handleCreateUser} isCreating={createUserMutation.isPending} isCreateButtonDisabled={createUserMutation.isPending || !userFormData.username || !userFormData.email} /> )} {/* Edit User Modal */} {showEditUserModal && editingUserId !== null && (
{ setShowEditUserModal(false); setEditingUserId(null); setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] }); }} > e.stopPropagation()} >

{t('settings.editUser')}

{/* Username Field */}
setUserFormData({ ...userFormData, username: e.target.value })} className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors" placeholder={t('settings.enterUsername')} autoComplete="username" />
{/* Email Field */}
setUserFormData({ ...userFormData, email: e.target.value })} className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors" placeholder={t('users.form.emailPlaceholder') || 'user@example.com'} required={advancedAuthStatus?.advanced_auth_enabled} />
{/* Password Fields - only show when Advanced Auth is disabled */} {!advancedAuthStatus?.advanced_auth_enabled && ( <>
setUserFormData({ ...userFormData, password: e.target.value, confirmPassword: '' })} className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors" placeholder={t('settings.enterNewPassword')} autoComplete="new-password" minLength={6} />
{userFormData.password && (
setUserFormData({ ...userFormData, confirmPassword: e.target.value })} className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${ userFormData.confirmPassword && userFormData.password !== userFormData.confirmPassword ? 'border-red-500' : 'border-bambu-dark-tertiary' }`} placeholder={t('settings.confirmNewPassword')} autoComplete="new-password" minLength={6} /> {userFormData.confirmPassword && userFormData.password !== userFormData.confirmPassword && (

{t('settings.passwordsDoNotMatch')}

)}
)} )} {/* Info box about auto-generated password when Advanced Auth is enabled */} {advancedAuthStatus?.advanced_auth_enabled && (

{t('users.form.passwordManagedByAdvancedAuth') || 'Password is managed by Advanced Authentication. Use "Reset Password" to send a new password to the user via email.'}

)} {/* Groups Field */}
{groupsData.map(group => ( ))}
)} {/* Delete User Confirmation Modal */} {deleteUserId !== null && (
{ setDeleteUserId(null); setDeleteUserItemCounts(null); }} > e.stopPropagation()} >

{t('settings.deleteUserTitle')}

{deleteUserLoading ? (
) : deleteUserItemCounts && (deleteUserItemCounts.archives + deleteUserItemCounts.queue_items + deleteUserItemCounts.library_files > 0) ? ( <>

{t('settings.userHasCreated')}

    {deleteUserItemCounts.archives > 0 && (
  • {deleteUserItemCounts.archives} archive{deleteUserItemCounts.archives !== 1 ? 's' : ''}
  • )} {deleteUserItemCounts.queue_items > 0 && (
  • {deleteUserItemCounts.queue_items} queue item{deleteUserItemCounts.queue_items !== 1 ? 's' : ''}
  • )} {deleteUserItemCounts.library_files > 0 && (
  • {deleteUserItemCounts.library_files} library file{deleteUserItemCounts.library_files !== 1 ? 's' : ''}
  • )}

{t('settings.userItemsQuestion')}

) : ( <>

{t('settings.deleteUserConfirm')}

{t('settings.actionCannotBeUndone')}

)}
)} {/* Create/Edit Group Modal */} {(showCreateGroupModal || editingGroup) && (
{ setShowCreateGroupModal(false); setEditingGroup(null); resetGroupForm(); }} > e.stopPropagation()} >

{editingGroup ? 'Edit Group' : 'Create Group'}

setGroupFormData({ ...groupFormData, name: e.target.value })} disabled={editingGroup?.is_system} className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors disabled:opacity-50" placeholder={t('settings.enterGroupName')} /> {editingGroup?.is_system && (

{t('settings.systemGroupWarning')}

)}