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, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { api } from '../api/client'; import type { AppSettings, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus } 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 { BackupModal } from '../components/BackupModal'; import { RestoreModal } from '../components/RestoreModal'; import { SpoolmanSettings } from '../components/SpoolmanSettings'; import { ExternalLinksSettings } from '../components/ExternalLinksSettings'; import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout'; import { availableLanguages } from '../i18n'; import { useToast } from '../contexts/ToastContext'; import { useState, useEffect, useRef, useCallback } from 'react'; export function SettingsPage() { const queryClient = useQueryClient(); const { t, i18n } = useTranslation(); const { showToast } = useToast(); 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()); const [activeTab, setActiveTab] = useState<'general' | 'plugs' | 'notifications' | 'apikeys'>('general'); 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); // Confirm modal states const [showClearLogsConfirm, setShowClearLogsConfirm] = useState(false); const [showClearStorageConfirm, setShowClearStorageConfirm] = useState(false); const [showBulkPlugConfirm, setShowBulkPlugConfirm] = useState<'on' | 'off' | null>(null); const [showBackupModal, setShowBackupModal] = useState(false); const [showRestoreModal, setShowRestoreModal] = useState(false); const handleDefaultViewChange = (path: string) => { setDefaultViewState(path); setDefaultView(path); }; 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 { status } of statuses) { if (status?.reachable && status.energy) { 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, enabled: activeTab === 'apikeys', }); 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('API key created'); }, 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('API key deleted'); }, 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, }); 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; }, }); const applyUpdateMutation = useMutation({ mutationFn: api.applyUpdate, onSuccess: () => { 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 isInitialLoadRef = useRef(true); // Sync local state when settings load useEffect(() => { if (settings && !localSettings) { setLocalSettings(settings); // 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); // Invalidate archive stats to reflect energy tracking mode change queryClient.invalidateQueries({ queryKey: ['archiveStats'] }); showToast('Settings saved', 'success'); }, onError: (error: Error) => { showToast(`Failed to save: ${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.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.date_format !== localSettings.date_format || settings.time_format !== localSettings.time_format || settings.default_printer_id !== localSettings.default_printer_id; if (!hasChanges) { return; } // Clear existing timeout if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); } // Set new debounced save (500ms delay) saveTimeoutRef.current = setTimeout(() => { updateMutation.mutate(localSettings); }, 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); }, []); if (isLoading || !localSettings) { return (
); } return (

Settings

Configure Bambuddy

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

Sidebar order

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

Archive Settings

Auto-archive prints

Automatically save 3MF files when prints complete

Save thumbnails

Extract and save preview images from 3MF files

Capture finish photo

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).

)}

Cost Tracking

updateSetting('default_filament_cost', parseFloat(e.target.value) || 0) } 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('energy_cost_per_kwh', parseFloat(e.target.value) || 0) } 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.energy_tracking_mode === 'print' ? 'Dashboard shows sum of energy used during prints' : 'Dashboard shows lifetime energy from smart plugs'}

{/* Second Column - AMS & Spoolman */}

AMS Display Thresholds

Configure color thresholds for AMS humidity and temperature indicators.

{/* Humidity Thresholds */}
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" /> %

Above fair threshold shows as red (bad)

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

Above fair threshold shows as red (hot)

{/* History Retention */}
History Retention
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" /> days

Older humidity and temperature data will be automatically deleted

{/* Third Column - Updates */}

Updates

Check for updates

Automatically check for new versions on startup

Current version

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_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?.error ? (
Failed to check for updates: {updateCheck.error}
) : updateCheck && !updateCheck.update_available ? (

You're running the latest version

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

Data Management

{/* Backup/Restore */}

Backup Data

Export settings, providers, printers, and more

Restore Backup

Import settings from a backup file with duplicate handling options

Clear Notification Logs

Delete notification logs older than 30 days

Reset UI Preferences

Reset sidebar order, theme, view modes, and layout preferences. Printers, archives, and settings are not affected.

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

Smart Plugs

Connect Tasmota-based smart plugs 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 && localSettings.energy_cost_per_kwh > 0 && (
~{(plugEnergySummary.totalToday * localSettings.energy_cost_per_kwh).toFixed(2)} {localSettings.currency}
)}
{/* Yesterday */}
Yesterday
{plugEnergySummary.totalYesterday.toFixed(2)} kWh
{localSettings && localSettings.energy_cost_per_kwh > 0 && (
~{(plugEnergySummary.totalYesterday * localSettings.energy_cost_per_kwh).toFixed(2)} {localSettings.currency}
)}
{/* Total Lifetime */}
Total
{plugEnergySummary.totalLifetime.toFixed(1)} kWh
{localSettings && localSettings.energy_cost_per_kwh > 0 && (
~{(plugEnergySummary.totalLifetime * localSettings.energy_cost_per_kwh).toFixed(2)} {localSettings.currency}
)}
) : !energyLoading ? (

Enable plugs to see energy summary

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

No smart plugs configured

Add a Tasmota-based smart plug to track energy usage and automate power control.

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

Providers

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

{t('settings.notificationLanguage')}

{t('settings.notificationLanguageDescription')}

{/* Test All Results */} {testAllResult && (
Test Results
{testAllResult.success} passed {testAllResult.failed > 0 && ( {testAllResult.failed} 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); }} /> ))}
) : (

No providers configured

Add a provider to receive alerts.

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

Message Templates

Customize notification messages for each event.

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

{template.name}

{template.title_template}

))}
) : (

No templates available. Restart the backend to seed default templates.

)}
)} {/* API Keys Tab */} {activeTab === 'apikeys' && (

API Keys

Create API keys for external integrations and webhooks. Use these keys to control your printers from automation tools like Home Assistant.

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

API Key Created Successfully

Copy this key now - it won't be shown again!

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

Create New API Key

setNewAPIKeyName(e.target.value)} placeholder="e.g., Home Assistant, OctoPrint" 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 && ` · Last used: ${new Date(key.last_used).toLocaleDateString()}`}

{key.can_read_status && ( Read )} {key.can_queue && ( Queue )} {key.can_control_printer && ( Control )}
))}
) : (

No API keys

Create an API key to integrate with external services.

)} {/* Webhook Documentation */}

Webhook Endpoints

Use your API key in the X-API-Key header.

GET{' '} /api/v1/webhook/status - Get all printer status
GET{' '} /api/v1/webhook/status/:id - Get specific printer status
POST{' '} /api/v1/webhook/queue - Add to print queue
POST{' '} /api/v1/webhook/printer/:id/pause - Pause print
POST{' '} /api/v1/webhook/printer/:id/resume - Resume print
POST{' '} /api/v1/webhook/printer/:id/stop - Stop print
)} {/* 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('Failed to clear logs', 'error'); } }} onCancel={() => setShowClearLogsConfirm(false)} /> )} {/* Confirm Modal: Clear Local Storage */} {showClearStorageConfirm && ( { setShowClearStorageConfirm(false); localStorage.clear(); showToast('UI preferences reset. Refreshing...', '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)} /> )} {/* Backup Modal */} {showBackupModal && ( setShowBackupModal(false)} onExport={async (categories) => { setShowBackupModal(false); try { const { blob, filename } = await api.exportBackup(categories); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); showToast('Backup downloaded', 'success'); } catch (err) { showToast('Failed to create backup', 'error'); } }} /> )} {/* Restore Modal */} {showRestoreModal && ( setShowRestoreModal(false)} onRestore={async (file, overwrite) => { return await api.importBackup(file, overwrite); }} onSuccess={() => { queryClient.invalidateQueries(); }} /> )}
); }