From d5c93fd226bd3f51fbca92abb1429799c44de5bf Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 8 Jan 2026 23:04:25 +0000 Subject: [PATCH] fix: add cluster endpoint IP override and Windows agent download support 1. Add IPOverride field to ClusterEndpoint struct - Allows users to specify a custom IP that takes precedence over auto-discovered IPs - Fixes #929 and #1066 where Pulse used internal cluster IPs instead of management IPs - Added EffectiveIP() method to cleanly handle the override logic 2. Update connection code to use EffectiveIP() - monitor.go: Use override when building endpoint URLs - temperature_proxy.go: Use override for proxy connections 3. Add bare Windows EXE files to GitHub releases - Fixes #1064 where LXC/barebone installs couldn't download Windows agents - Modified build-release.sh to copy EXEs alongside ZIPs - Added EXEs to checksum generation --- .../src/components/Settings/AISettings.tsx | 2767 ++++---- .../src/components/Settings/AuditLogPanel.tsx | 2 +- .../src/components/Settings/Settings.tsx | 5658 +++++++++-------- frontend-modern/src/stores/license.ts | 66 + internal/api/temperature_proxy.go | 3 +- internal/config/config.go | 11 +- internal/monitoring/monitor.go | 19 +- scripts/build-release.sh | 16 +- 8 files changed, 4318 insertions(+), 4224 deletions(-) create mode 100644 frontend-modern/src/stores/license.ts diff --git a/frontend-modern/src/components/Settings/AISettings.tsx b/frontend-modern/src/components/Settings/AISettings.tsx index 639b73f47..61b3efb69 100644 --- a/frontend-modern/src/components/Settings/AISettings.tsx +++ b/frontend-modern/src/components/Settings/AISettings.tsx @@ -274,1110 +274,459 @@ export const AISettings: Component = () => { // Clean up URL window.history.replaceState({}, '', window.location.pathname); } -}); -// Note: handleProviderChange is no longer used as we now use multi-provider accordions -// The provider is implicitly determined by the selected model (e.g., "anthropic:claude-opus") + // Note: handleProviderChange is no longer used as we now use multi-provider accordions + // The provider is implicitly determined by the selected model (e.g., "anthropic:claude-opus") -const handleSave = async (event?: Event) => { - event?.preventDefault(); + const handleSave = async (event?: Event) => { + event?.preventDefault(); - // Frontend validation: warn if model's provider isn't configured - const selectedModel = form.model.trim(); - if (selectedModel && form.enabled) { - const modelProvider = getProviderFromModelId(selectedModel); - if (!isProviderConfigured(modelProvider, settings())) { - // Check if any API key is being added in this save for this provider - const isAddingCredential = - (modelProvider === 'anthropic' && form.anthropicApiKey.trim()) || - (modelProvider === 'openai' && form.openaiApiKey.trim()) || - (modelProvider === 'deepseek' && form.deepseekApiKey.trim()) || - (modelProvider === 'gemini' && form.geminiApiKey.trim()) || - (modelProvider === 'ollama' && form.ollamaBaseUrl.trim()); + // Frontend validation: warn if model's provider isn't configured + const selectedModel = form.model.trim(); + if (selectedModel && form.enabled) { + const modelProvider = getProviderFromModelId(selectedModel); + if (!isProviderConfigured(modelProvider, settings())) { + // Check if any API key is being added in this save for this provider + const isAddingCredential = + (modelProvider === 'anthropic' && form.anthropicApiKey.trim()) || + (modelProvider === 'openai' && form.openaiApiKey.trim()) || + (modelProvider === 'deepseek' && form.deepseekApiKey.trim()) || + (modelProvider === 'gemini' && form.geminiApiKey.trim()) || + (modelProvider === 'ollama' && form.ollamaBaseUrl.trim()); - if (!isAddingCredential) { - notificationStore.error( - `Cannot save: Model "${selectedModel}" requires ${PROVIDER_DISPLAY_NAMES[modelProvider] || modelProvider} to be configured. ` + - `Please add an API key for ${PROVIDER_DISPLAY_NAMES[modelProvider] || modelProvider} or select a different model.` - ); - return; - } - } - } - - // Validate patrol interval (must be 0 or >= 10) - if (form.patrolIntervalMinutes > 0 && form.patrolIntervalMinutes < 10) { - notificationStore.error('Patrol interval must be at least 10 minutes (or 0 to disable)'); - return; - } - - setSaving(true); - try { - const payload: Record = { - provider: form.provider, - model: selectedModel, - }; - - // Only include base_url if it's set or if provider is ollama - if (form.baseUrl.trim() || form.provider === 'ollama') { - payload.base_url = form.baseUrl.trim(); - } - - // Handle API key - if (form.apiKey.trim() !== '') { - payload.api_key = form.apiKey.trim(); - } else if (form.clearApiKey) { - payload.api_key = ''; - } - - // Only include enabled if we're toggling it - if (form.enabled !== settings()?.enabled) { - payload.enabled = form.enabled; - } - - // Include autonomous mode if changed - if (form.autonomousMode !== settings()?.autonomous_mode) { - payload.autonomous_mode = form.autonomousMode; - } - - // Include patrol settings if changed - if (form.patrolIntervalMinutes !== (settings()?.patrol_interval_minutes ?? 360)) { - payload.patrol_interval_minutes = form.patrolIntervalMinutes; - } - - if (form.alertTriggeredAnalysis !== settings()?.alert_triggered_analysis) { - payload.alert_triggered_analysis = form.alertTriggeredAnalysis; - } - - if (form.patrolAutoFix !== settings()?.patrol_auto_fix) { - payload.patrol_auto_fix = form.patrolAutoFix; - } - - // Include model overrides if changed - if (form.chatModel !== (settings()?.chat_model || '')) { - payload.chat_model = form.chatModel; - } - - if (form.patrolModel !== (settings()?.patrol_model || '')) { - payload.patrol_model = form.patrolModel; - } - - if (form.autoFixModel !== (settings()?.auto_fix_model || '')) { - payload.auto_fix_model = form.autoFixModel; - } - - // Include multi-provider credentials if set (non-empty) - if (form.anthropicApiKey.trim()) { - payload.anthropic_api_key = form.anthropicApiKey.trim(); - } - if (form.openaiApiKey.trim()) { - payload.openai_api_key = form.openaiApiKey.trim(); - } - if (form.deepseekApiKey.trim()) { - payload.deepseek_api_key = form.deepseekApiKey.trim(); - } - if (form.geminiApiKey.trim()) { - payload.gemini_api_key = form.geminiApiKey.trim(); - } - // Always include Ollama URL if it has a value and differs from what's saved - // Compare against actual saved value (empty string if not set), not a prefilled default - if (form.ollamaBaseUrl.trim() && form.ollamaBaseUrl.trim() !== (settings()?.ollama_base_url || '')) { - payload.ollama_base_url = form.ollamaBaseUrl.trim(); - } - if (form.openaiBaseUrl !== (settings()?.openai_base_url || '')) { - payload.openai_base_url = form.openaiBaseUrl.trim(); - } - - // Cost controls (server-side budget, cross-provider estimate) - { - const raw = form.costBudgetUSD30d.trim(); - const parsed = raw === '' ? 0 : Number(raw); - if (!Number.isFinite(parsed) || parsed < 0) { - notificationStore.error('Cost budget must be a non-negative number'); - return; - } - const current = settings()?.cost_budget_usd_30d ?? 0; - if (Math.abs(parsed - current) > 0.0001) { - payload.cost_budget_usd_30d = parsed; + if (!isAddingCredential) { + notificationStore.error( + `Cannot save: Model "${selectedModel}" requires ${PROVIDER_DISPLAY_NAMES[modelProvider] || modelProvider} to be configured. ` + + `Please add an API key for ${PROVIDER_DISPLAY_NAMES[modelProvider] || modelProvider} or select a different model.` + ); + return; + } } } - // Request timeout (for slow Ollama hardware) - if (form.requestTimeoutSeconds !== (settings()?.request_timeout_seconds ?? 300)) { - payload.request_timeout_seconds = form.requestTimeoutSeconds; + // Validate patrol interval (must be 0 or >= 10) + if (form.patrolIntervalMinutes > 0 && form.patrolIntervalMinutes < 10) { + notificationStore.error('Patrol interval must be at least 10 minutes (or 0 to disable)'); + return; } - const updated = await AIAPI.updateSettings(payload); - setSettings(updated); - resetForm(updated); - notificationStore.success('AI settings saved'); - } catch (error) { - logger.error('[AISettings] Failed to save settings:', error); - const message = error instanceof Error ? error.message : 'Failed to save AI settings'; - notificationStore.error(message); - } finally { - setSaving(false); - } -}; + setSaving(true); + try { + const payload: Record = { + provider: form.provider, + model: selectedModel, + }; -const handleTest = async () => { - setTesting(true); - try { - const result = await AIAPI.testConnection(); - if (result.success) { - notificationStore.success(result.message); + // Only include base_url if it's set or if provider is ollama + if (form.baseUrl.trim() || form.provider === 'ollama') { + payload.base_url = form.baseUrl.trim(); + } + + // Handle API key + if (form.apiKey.trim() !== '') { + payload.api_key = form.apiKey.trim(); + } else if (form.clearApiKey) { + payload.api_key = ''; + } + + // Only include enabled if we're toggling it + if (form.enabled !== settings()?.enabled) { + payload.enabled = form.enabled; + } + + // Include autonomous mode if changed + if (form.autonomousMode !== settings()?.autonomous_mode) { + payload.autonomous_mode = form.autonomousMode; + } + + // Include patrol settings if changed + if (form.patrolIntervalMinutes !== (settings()?.patrol_interval_minutes ?? 360)) { + payload.patrol_interval_minutes = form.patrolIntervalMinutes; + } + + if (form.alertTriggeredAnalysis !== settings()?.alert_triggered_analysis) { + payload.alert_triggered_analysis = form.alertTriggeredAnalysis; + } + + if (form.patrolAutoFix !== settings()?.patrol_auto_fix) { + payload.patrol_auto_fix = form.patrolAutoFix; + } + + // Include model overrides if changed + if (form.chatModel !== (settings()?.chat_model || '')) { + payload.chat_model = form.chatModel; + } + + if (form.patrolModel !== (settings()?.patrol_model || '')) { + payload.patrol_model = form.patrolModel; + } + + if (form.autoFixModel !== (settings()?.auto_fix_model || '')) { + payload.auto_fix_model = form.autoFixModel; + } + + // Include multi-provider credentials if set (non-empty) + if (form.anthropicApiKey.trim()) { + payload.anthropic_api_key = form.anthropicApiKey.trim(); + } + if (form.openaiApiKey.trim()) { + payload.openai_api_key = form.openaiApiKey.trim(); + } + if (form.deepseekApiKey.trim()) { + payload.deepseek_api_key = form.deepseekApiKey.trim(); + } + if (form.geminiApiKey.trim()) { + payload.gemini_api_key = form.geminiApiKey.trim(); + } + // Always include Ollama URL if it has a value and differs from what's saved + // Compare against actual saved value (empty string if not set), not a prefilled default + if (form.ollamaBaseUrl.trim() && form.ollamaBaseUrl.trim() !== (settings()?.ollama_base_url || '')) { + payload.ollama_base_url = form.ollamaBaseUrl.trim(); + } + if (form.openaiBaseUrl !== (settings()?.openai_base_url || '')) { + payload.openai_base_url = form.openaiBaseUrl.trim(); + } + + // Cost controls (server-side budget, cross-provider estimate) + { + const raw = form.costBudgetUSD30d.trim(); + const parsed = raw === '' ? 0 : Number(raw); + if (!Number.isFinite(parsed) || parsed < 0) { + notificationStore.error('Cost budget must be a non-negative number'); + return; + } + const current = settings()?.cost_budget_usd_30d ?? 0; + if (Math.abs(parsed - current) > 0.0001) { + payload.cost_budget_usd_30d = parsed; + } + } + + // Request timeout (for slow Ollama hardware) + if (form.requestTimeoutSeconds !== (settings()?.request_timeout_seconds ?? 300)) { + payload.request_timeout_seconds = form.requestTimeoutSeconds; + } + + const updated = await AIAPI.updateSettings(payload); + setSettings(updated); + resetForm(updated); + notificationStore.success('AI settings saved'); + } catch (error) { + logger.error('[AISettings] Failed to save settings:', error); + const message = error instanceof Error ? error.message : 'Failed to save AI settings'; + notificationStore.error(message); + } finally { + setSaving(false); + } + }; + + const handleTest = async () => { + setTesting(true); + try { + const result = await AIAPI.testConnection(); + if (result.success) { + notificationStore.success(result.message); + } else { + notificationStore.error(result.message); + } + } catch (error) { + logger.error('[AISettings] Test failed:', error); + const message = error instanceof Error ? error.message : 'Connection test failed'; + notificationStore.error(message); + } finally { + setTesting(false); + } + }; + + const handleTestProvider = async (provider: string) => { + setTestingProvider(provider); + setProviderTestResult(null); + try { + const result = await AIAPI.testProvider(provider); + setProviderTestResult(result); + if (result.success) { + notificationStore.success(`${provider}: ${result.message}`); + } else { + notificationStore.error(`${provider}: ${result.message}`); + } + } catch (error) { + logger.error(`[AISettings] Test ${provider} failed:`, error); + const message = error instanceof Error ? error.message : 'Connection test failed'; + setProviderTestResult({ provider, success: false, message }); + notificationStore.error(`${provider}: ${message}`); + } finally { + setTestingProvider(null); + } + }; + + const handleClearProvider = async (provider: string) => { + // Check if this is the last configured provider + const s = settings(); + const configuredCount = [s?.anthropic_configured, s?.openai_configured, s?.deepseek_configured, s?.gemini_configured, s?.ollama_configured].filter(Boolean).length; + const isLastProvider = configuredCount === 1 && isProviderConfigured(provider, s); + + // Check if current model uses this provider + const currentModel = form.model.trim(); + const modelUsesProvider = currentModel && getProviderFromModelId(currentModel) === provider; + + let confirmMessage = `Clear ${PROVIDER_DISPLAY_NAMES[provider] || provider} credentials?`; + if (isLastProvider) { + confirmMessage = `⚠️ This is your only configured provider! Clearing it will disable AI until you configure another provider. Continue?`; + } else if (modelUsesProvider) { + confirmMessage = `Your current model uses ${PROVIDER_DISPLAY_NAMES[provider] || provider}. Clearing this will require selecting a different model. Continue?`; } else { - notificationStore.error(result.message); + confirmMessage += ` You'll need to re-enter credentials to use this provider.`; } - } catch (error) { - logger.error('[AISettings] Test failed:', error); - const message = error instanceof Error ? error.message : 'Connection test failed'; - notificationStore.error(message); - } finally { - setTesting(false); - } -}; -const handleTestProvider = async (provider: string) => { - setTestingProvider(provider); - setProviderTestResult(null); - try { - const result = await AIAPI.testProvider(provider); - setProviderTestResult(result); - if (result.success) { - notificationStore.success(`${provider}: ${result.message}`); - } else { - notificationStore.error(`${provider}: ${result.message}`); + if (!confirm(confirmMessage)) { + return; } - } catch (error) { - logger.error(`[AISettings] Test ${provider} failed:`, error); - const message = error instanceof Error ? error.message : 'Connection test failed'; - setProviderTestResult({ provider, success: false, message }); - notificationStore.error(`${provider}: ${message}`); - } finally { - setTestingProvider(null); - } -}; -const handleClearProvider = async (provider: string) => { - // Check if this is the last configured provider - const s = settings(); - const configuredCount = [s?.anthropic_configured, s?.openai_configured, s?.deepseek_configured, s?.gemini_configured, s?.ollama_configured].filter(Boolean).length; - const isLastProvider = configuredCount === 1 && isProviderConfigured(provider, s); + setSaving(true); + try { + const clearPayload: Record = {}; + if (provider === 'anthropic') clearPayload.clear_anthropic_key = true; + if (provider === 'openai') clearPayload.clear_openai_key = true; + if (provider === 'deepseek') clearPayload.clear_deepseek_key = true; + if (provider === 'gemini') clearPayload.clear_gemini_key = true; + if (provider === 'ollama') clearPayload.clear_ollama_url = true; - // Check if current model uses this provider - const currentModel = form.model.trim(); - const modelUsesProvider = currentModel && getProviderFromModelId(currentModel) === provider; + await AIAPI.updateSettings(clearPayload); - let confirmMessage = `Clear ${PROVIDER_DISPLAY_NAMES[provider] || provider} credentials?`; - if (isLastProvider) { - confirmMessage = `⚠️ This is your only configured provider! Clearing it will disable AI until you configure another provider. Continue?`; - } else if (modelUsesProvider) { - confirmMessage = `Your current model uses ${PROVIDER_DISPLAY_NAMES[provider] || provider}. Clearing this will require selecting a different model. Continue?`; - } else { - confirmMessage += ` You'll need to re-enter credentials to use this provider.`; - } + // Reload settings to reflect the change + const newSettings = await AIAPI.getSettings(); + setSettings(newSettings); - if (!confirm(confirmMessage)) { - return; - } + // Clear the local form field + if (provider === 'anthropic') setForm('anthropicApiKey', ''); + if (provider === 'openai') setForm('openaiApiKey', ''); + if (provider === 'deepseek') setForm('deepseekApiKey', ''); + if (provider === 'gemini') setForm('geminiApiKey', ''); + if (provider === 'ollama') setForm('ollamaBaseUrl', ''); - setSaving(true); - try { - const clearPayload: Record = {}; - if (provider === 'anthropic') clearPayload.clear_anthropic_key = true; - if (provider === 'openai') clearPayload.clear_openai_key = true; - if (provider === 'deepseek') clearPayload.clear_deepseek_key = true; - if (provider === 'gemini') clearPayload.clear_gemini_key = true; - if (provider === 'ollama') clearPayload.clear_ollama_url = true; + notificationStore.success(`${provider} credentials cleared`); + } catch (error) { + logger.error(`[AISettings] Clear ${provider} failed:`, error); + const message = error instanceof Error ? error.message : 'Failed to clear credentials'; + notificationStore.error(message); + } finally { + setSaving(false); + } + }; - await AIAPI.updateSettings(clearPayload); + // OAuth handlers removed - OAuth is currently unavailable from Anthropic for third-party apps + // When OAuth becomes available, handlers can be added back to the Anthropic accordion section - // Reload settings to reflect the change - const newSettings = await AIAPI.getSettings(); - setSettings(newSettings); + // Legacy helper functions removed - multi-provider accordions handle all provider-specific UI - // Clear the local form field - if (provider === 'anthropic') setForm('anthropicApiKey', ''); - if (provider === 'openai') setForm('openaiApiKey', ''); - if (provider === 'deepseek') setForm('deepseekApiKey', ''); - if (provider === 'gemini') setForm('geminiApiKey', ''); - if (provider === 'ollama') setForm('ollamaBaseUrl', ''); + return ( + <> + +
+
+
+ + + +
+ + {/* Toggle with first-time setup flow */} + {(() => { + const s = settings(); + const hasConfiguredProvider = s && (s.anthropic_configured || s.openai_configured || s.deepseek_configured || s.ollama_configured); - notificationStore.success(`${provider} credentials cleared`); - } catch (error) { - logger.error(`[AISettings] Clear ${provider} failed:`, error); - const message = error instanceof Error ? error.message : 'Failed to clear credentials'; - notificationStore.error(message); - } finally { - setSaving(false); - } -}; - -// OAuth handlers removed - OAuth is currently unavailable from Anthropic for third-party apps -// When OAuth becomes available, handlers can be added back to the Anthropic accordion section - -// Legacy helper functions removed - multi-provider accordions handle all provider-specific UI - -return ( - <> - -
-
-
- - - + return ( + { + const newValue = event.currentTarget.checked; + // Show setup modal if trying to enable without a configured provider + if (newValue && !hasConfiguredProvider) { + event.currentTarget.checked = false; + setShowSetupModal(true); + return; + } + setForm('enabled', newValue); + // Auto-save the enabled toggle immediately + try { + const updated = await AIAPI.updateSettings({ enabled: newValue }); + setSettings(updated); + notificationStore.success(newValue ? 'AI Assistant enabled' : 'AI Assistant disabled'); + } catch (error) { + // Revert on failure + setForm('enabled', !newValue); + logger.error('[AISettings] Failed to toggle AI:', error); + const message = error instanceof Error ? error.message : 'Failed to update AI setting'; + notificationStore.error(message); + } + }} + disabled={loading() || saving()} + containerClass="items-center gap-2" + label={ + + {form.enabled ? 'Enabled' : 'Disabled'} + + } + /> + ); + })()}
- - {/* Toggle with first-time setup flow */} - {(() => { - const s = settings(); - const hasConfiguredProvider = s && (s.anthropic_configured || s.openai_configured || s.deepseek_configured || s.ollama_configured); - - return ( - { - const newValue = event.currentTarget.checked; - // Show setup modal if trying to enable without a configured provider - if (newValue && !hasConfiguredProvider) { - event.currentTarget.checked = false; - setShowSetupModal(true); - return; - } - setForm('enabled', newValue); - // Auto-save the enabled toggle immediately - try { - const updated = await AIAPI.updateSettings({ enabled: newValue }); - setSettings(updated); - notificationStore.success(newValue ? 'AI Assistant enabled' : 'AI Assistant disabled'); - } catch (error) { - // Revert on failure - setForm('enabled', !newValue); - logger.error('[AISettings] Failed to toggle AI:', error); - const message = error instanceof Error ? error.message : 'Failed to update AI setting'; - notificationStore.error(message); - } - }} - disabled={loading() || saving()} - containerClass="items-center gap-2" - label={ - - {form.enabled ? 'Enabled' : 'Disabled'} - - } - /> - ); - })()}
-
-
- -
- - Loading AI settings... -
-
+ + +
+ + Loading AI settings... +
+
- -
- {/* Default Model Selection - Always visible */} -
-
- + +
+ {/* Default Model Selection - Always visible */} +
+
+ + +
+ 0} fallback={ + setForm('model', e.currentTarget.value)} + placeholder="Configure a provider below to see available models" + class={controlClass()} + disabled={saving()} + /> + }> + + + {/* Warning if selected model's provider is not configured */} + +

+ + + + This model requires {PROVIDER_DISPLAY_NAMES[getProviderFromModelId(form.model)] || getProviderFromModelId(form.model)} to be configured. + Add an API key below or select a different model. +

+
+
+ + {/* Advanced Model Selection - Collapsible */} +
-
- 0} fallback={ - setForm('model', e.currentTarget.value)} - placeholder="Configure a provider below to see available models" - class={controlClass()} - disabled={saving()} - /> - }> - - - {/* Warning if selected model's provider is not configured */} - -

- - - - This model requires {PROVIDER_DISPLAY_NAMES[getProviderFromModelId(form.model)] || getProviderFromModelId(form.model)} to be configured. - Add an API key below or select a different model. -

-
-
- - {/* Advanced Model Selection - Collapsible */} -
- - -
-

- Override the default model for specific tasks. Leave empty to use the default. -

- {/* Chat Model */} -
- - 0} fallback={ - setForm('chatModel', e.currentTarget.value)} - placeholder="Use default model" - class={controlClass()} - disabled={saving()} - /> - }> - - -
- {/* Patrol Model */} -
- - 0} fallback={ - setForm('patrolModel', e.currentTarget.value)} - placeholder="Use default model" - class={controlClass()} - disabled={saving()} - /> - }> - - -
-
-
-
- - {/* AI Provider Configuration - Configure API keys for all providers */} -
-
-

- - - - AI Provider Configuration -

-

- Configure API keys for each AI provider you want to use. Models from all configured providers will appear in the model selectors. -

-
- - {/* Provider Accordions */} -
- {/* Anthropic */} -
- - -
- setForm('anthropicApiKey', e.currentTarget.value)} - placeholder={settings()?.anthropic_configured ? '••••••••••• (configured)' : 'sk-ant-...'} - class={controlClass()} - disabled={saving()} - /> -
-

- Get API key → -

- -
- - -
-
-
- -

- {providerTestResult()?.message} -

-
-
-
-
- - {/* OpenAI */} -
- - -
- setForm('openaiApiKey', e.currentTarget.value)} - placeholder={settings()?.openai_configured ? '••••••••••• (configured)' : 'sk-...'} - class={controlClass()} - disabled={saving()} - /> -
- - setForm('openaiBaseUrl', e.currentTarget.value)} - placeholder="https://openrouter.ai/api/v1 (optional)" - class={controlClass()} - disabled={saving()} - /> -
-
-

- Get API key → -

- -
- - -
-
-
- -

- {providerTestResult()?.message} -

-
-
-
-
- - {/* DeepSeek */} -
- - -
- setForm('deepseekApiKey', e.currentTarget.value)} - placeholder={settings()?.deepseek_configured ? '••••••••••• (configured)' : 'sk-...'} - class={controlClass()} - disabled={saving()} - /> -
-

- Get API key → -

- -
- - -
-
-
- -

- {providerTestResult()?.message} -

-
-
-
-
- - {/* Google Gemini */} -
- - -
- setForm('geminiApiKey', e.currentTarget.value)} - placeholder={settings()?.gemini_configured ? '••••••••••• (configured)' : 'AIza...'} - class={controlClass()} - disabled={saving()} - /> -
-

- Get API key → -

- -
- - -
-
-
- -

- {providerTestResult()?.message} -

-
-
-
-
- - {/* Ollama */} -
- - -
-
- - setForm('ollamaBaseUrl', e.currentTarget.value)} - placeholder="http://localhost:11434" - class={controlClass()} - disabled={saving()} - /> -
-
-

- Learn about Ollama → - · Free & local -

- -
- - -
-
-
- -

- {providerTestResult()?.message} -

-
-
-
-
-
-
- - {/* Autonomous Mode */} -
-
-
- -

- {form.autonomousMode - ? 'AI will execute all commands without asking for approval. Only enable if you trust your configured model.' - : 'AI will ask for approval before running commands that modify your system. Read-only commands (like df, ps, docker stats) run automatically.'} -

-
- ⚠️ Legal Disclaimer: AI models can hallucinate. You are responsible for any damage caused by autonomous actions. See Terms of Service. -
- -

- Pulse Enterprise required for autonomous mode.{' '} - - Upgrade - + +

+

+ Override the default model for specific tasks. Leave empty to use the default.

- -
- setForm('autonomousMode', event.currentTarget.checked)} - disabled={saving() || autoFixLocked()} - /> -
-
- - {/* AI Patrol & Efficiency Settings - Collapsible */} -
- - -
- {/* Patrol Interval - Compact */} -
-
- - 0 && form.patrolIntervalMinutes < 10 - ? 'border-red-300 dark:border-red-600' - : 'border-gray-300 dark:border-gray-600' - }`} - value={form.patrolIntervalMinutes} - onInput={(e) => { - const value = parseInt(e.currentTarget.value, 10); - if (!isNaN(value)) setForm('patrolIntervalMinutes', Math.max(0, value)); - }} - min={0} - max={10080} - step={15} - disabled={saving()} - /> - min (0=off, 10+ to enable) -
- 0 && form.patrolIntervalMinutes < 10}> -

Minimum interval is 10 minutes

-
-
- - {/* Alert Analysis Toggle - Compact */} -
- - setForm('alertTriggeredAnalysis', event.currentTarget.checked)} - disabled={saving() || alertAnalysisLocked()} - /> -
- -

- Pulse Enterprise required for alert-triggered analysis.{' '} - - Upgrade - -

-
- - {/* Auto-Fix Toggle - Compact with inline warning */} -
-
- - - setForm('patrolAutoFix', event.currentTarget.checked)} - disabled={saving() || autoFixLocked()} - /> - - - - -
- -

- Pulse Enterprise required for auto-fix.{' '} - - Upgrade - -

-
- -

- ⚠️ AI will execute fixes without approval. Enable with caution. -

-
- -

- - - - Auto-Fix is ON. AI will attempt automatic remediation. -

-
-
- - {/* Auto-Fix Model - Only when enabled */} - -
- + {/* Chat Model */} +
+ 0} fallback={ setForm('autoFixModel', e.currentTarget.value)} - placeholder="Use patrol model" - class="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700" + value={form.chatModel} + onInput={(e) => setForm('chatModel', e.currentTarget.value)} + placeholder="Use default model" + class={controlClass()} disabled={saving()} /> }>
+ {/* Patrol Model */} +
+ + 0} fallback={ + setForm('patrolModel', e.currentTarget.value)} + placeholder="Use default model" + class={controlClass()} + disabled={saving()} + /> + }> + + +
+
+
+
+ + {/* AI Provider Configuration - Configure API keys for all providers */} +
+
+

+ + + + AI Provider Configuration +

+

+ Configure API keys for each AI provider you want to use. Models from all configured providers will appear in the model selectors. +

+
+ + {/* Provider Accordions */} +
+ {/* Anthropic */} +
+ + +
+ setForm('anthropicApiKey', e.currentTarget.value)} + placeholder={settings()?.anthropic_configured ? '••••••••••• (configured)' : 'sk-ant-...'} + class={controlClass()} + disabled={saving()} + /> +
+

+ Get API key → +

+ +
+ + +
+
+
+ +

+ {providerTestResult()?.message} +

+
+
+
+
+ + {/* OpenAI */} +
+ + +
+ setForm('openaiApiKey', e.currentTarget.value)} + placeholder={settings()?.openai_configured ? '••••••••••• (configured)' : 'sk-...'} + class={controlClass()} + disabled={saving()} + /> +
+ + setForm('openaiBaseUrl', e.currentTarget.value)} + placeholder="https://openrouter.ai/api/v1 (optional)" + class={controlClass()} + disabled={saving()} + /> +
+
+

+ Get API key → +

+ +
+ + +
+
+
+ +

+ {providerTestResult()?.message} +

+
+
+
+
+ + {/* DeepSeek */} +
+ + +
+ setForm('deepseekApiKey', e.currentTarget.value)} + placeholder={settings()?.deepseek_configured ? '••••••••••• (configured)' : 'sk-...'} + class={controlClass()} + disabled={saving()} + /> +
+

+ Get API key → +

+ +
+ + +
+
+
+ +

+ {providerTestResult()?.message} +

+
+
+
+
+ + {/* Google Gemini */} +
+ + +
+ setForm('geminiApiKey', e.currentTarget.value)} + placeholder={settings()?.gemini_configured ? '••••••••••• (configured)' : 'AIza...'} + class={controlClass()} + disabled={saving()} + /> +
+

+ Get API key → +

+ +
+ + +
+
+
+ +

+ {providerTestResult()?.message} +

+
+
+
+
+ + {/* Ollama */} +
+ + +
+
+ + setForm('ollamaBaseUrl', e.currentTarget.value)} + placeholder="http://localhost:11434" + class={controlClass()} + disabled={saving()} + /> +
+
+

+ Learn about Ollama → + · Free & local +

+ +
+ + +
+
+
+ +

+ {providerTestResult()?.message} +

+
+
+
+
+
+
+ + {/* Autonomous Mode */} +
+
+
+ +

+ {form.autonomousMode + ? 'AI will execute all commands without asking for approval. Only enable if you trust your configured model.' + : 'AI will ask for approval before running commands that modify your system. Read-only commands (like df, ps, docker stats) run automatically.'} +

+
+ ⚠️ Legal Disclaimer: AI models can hallucinate. You are responsible for any damage caused by autonomous actions. See Terms of Service. +
+ +

+ Pulse Enterprise required for autonomous mode.{' '} + + Upgrade + +

+
+
+ setForm('autonomousMode', event.currentTarget.checked)} + disabled={saving() || autoFixLocked()} + /> +
+
+ + {/* AI Patrol & Efficiency Settings - Collapsible */} +
+ + +
+ {/* Patrol Interval - Compact */} +
+
+ + 0 && form.patrolIntervalMinutes < 10 + ? 'border-red-300 dark:border-red-600' + : 'border-gray-300 dark:border-gray-600' + }`} + value={form.patrolIntervalMinutes} + onInput={(e) => { + const value = parseInt(e.currentTarget.value, 10); + if (!isNaN(value)) setForm('patrolIntervalMinutes', Math.max(0, value)); + }} + min={0} + max={10080} + step={15} + disabled={saving()} + /> + min (0=off, 10+ to enable) +
+ 0 && form.patrolIntervalMinutes < 10}> +

Minimum interval is 10 minutes

+
+
+ + {/* Alert Analysis Toggle - Compact */} +
+ + setForm('alertTriggeredAnalysis', event.currentTarget.checked)} + disabled={saving() || alertAnalysisLocked()} + /> +
+ +

+ Pulse Enterprise required for alert-triggered analysis.{' '} + + Upgrade + +

+
+ + {/* Auto-Fix Toggle - Compact with inline warning */} +
+
+ + + setForm('patrolAutoFix', event.currentTarget.checked)} + disabled={saving() || autoFixLocked()} + /> + + + + +
+ +

+ Pulse Enterprise required for auto-fix.{' '} + + Upgrade + +

+
+ +

+ ⚠️ AI will execute fixes without approval. Enable with caution. +

+
+ +

+ + + + Auto-Fix is ON. AI will attempt automatic remediation. +

+
+
+ + {/* Auto-Fix Model - Only when enabled */} + +
+ + 0} fallback={ + setForm('autoFixModel', e.currentTarget.value)} + placeholder="Use patrol model" + class="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700" + disabled={saving()} + /> + }> + + +
+
+
+
+
+ + {/* AI Cost Controls - Compact */} +
+ + + + +
+ $ + setForm('costBudgetUSD30d', e.currentTarget.value)} + min={0} + step={1} + placeholder="0" + disabled={saving()} + /> +
+ 0}> + ≈ ${(parseFloat(form.costBudgetUSD30d) / 30).toFixed(2)}/day + + + 💡 Set budget for alerts + +
+ + {/* Request Timeout - For slow Ollama hardware */} +
+ + + + + { + const value = parseInt(e.currentTarget.value, 10); + if (!isNaN(value) && value > 0) setForm('requestTimeoutSeconds', value); + }} + min={30} + max={3600} + step={30} + disabled={saving()} + /> + seconds + + Custom + + + default + +
+

+ 💡 Increase for slow Ollama hardware (default: 300s / 5 min) +

+ + +
+ + {/* Status indicator */} + +
+
+
+ + {settings()?.configured + ? `Ready • ${settings()?.configured_providers?.length || 0} provider${(settings()?.configured_providers?.length || 0) !== 1 ? 's' : ''} • ${availableModels().length} models` + : 'Configure at least one AI provider above to enable AI features'} + + + + • Default: {settings()?.model?.split(':').pop() || settings()?.model} +
+
+ + + {/* Actions - sticky at bottom for easy access */} +
+ + + +
+ + +
+
+ + + + + {/* First-time Setup Modal */} + +
+
+ {/* Header */} +
+

Set Up AI Assistant

+

Choose a provider to get started

+
+ + {/* Provider Selection */} +
+
+ + + + + +
+ + {/* API Key / URL Input */} + + + setSetupApiKey(e.currentTarget.value)} + placeholder={setupProvider() === 'anthropic' ? 'sk-ant-...' : setupProvider() === 'gemini' ? 'AIza...' : 'sk-...'} + class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-purple-500 focus:border-transparent" + /> +

+ + Get your API key → + +

+
+ }> +
+ + setSetupOllamaUrl(e.currentTarget.value)} + placeholder="http://localhost:11434" + class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-purple-500 focus:border-transparent" + /> +

+ Ollama runs locally - no API key needed +

+
- {/* AI Cost Controls - Compact */} -
- - - - -
- $ - setForm('costBudgetUSD30d', e.currentTarget.value)} - min={0} - step={1} - placeholder="0" - disabled={saving()} - /> -
- 0}> - ≈ ${(parseFloat(form.costBudgetUSD30d) / 30).toFixed(2)}/day - - - 💡 Set budget for alerts - -
- - {/* Request Timeout - For slow Ollama hardware */} -
- - - - - { - const value = parseInt(e.currentTarget.value, 10); - if (!isNaN(value) && value > 0) setForm('requestTimeoutSeconds', value); - }} - min={30} - max={3600} - step={30} - disabled={saving()} - /> - seconds - - Custom - - - default - -
-

- 💡 Increase for slow Ollama hardware (default: 300s / 5 min) -

- - -
- - {/* Status indicator */} - -
-
-
- - {settings()?.configured - ? `Ready • ${settings()?.configured_providers?.length || 0} provider${(settings()?.configured_providers?.length || 0) !== 1 ? 's' : ''} • ${availableModels().length} models` - : 'Configure at least one AI provider above to enable AI features'} - - - - • Default: {settings()?.model?.split(':').pop() || settings()?.model} - - -
-
- - - {/* Actions - sticky at bottom for easy access */} -
- + {/* Footer */} +
- -
- - -
-
-
- - - - {/* First-time Setup Modal */} - -
-
- {/* Header */} -
-

Set Up AI Assistant

-

Choose a provider to get started

-
- - {/* Provider Selection */} -
-
- - - - - -
- - {/* API Key / URL Input */} - - - setSetupApiKey(e.currentTarget.value)} - placeholder={setupProvider() === 'anthropic' ? 'sk-ant-...' : setupProvider() === 'gemini' ? 'AIza...' : 'sk-...'} - class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-purple-500 focus:border-transparent" - /> -

- - Get your API key → - -

-
- }> -
- - setSetupOllamaUrl(e.currentTarget.value)} - placeholder="http://localhost:11434" - class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-purple-500 focus:border-transparent" - /> -

- Ollama runs locally - no API key needed -

-
- -
- - {/* Footer */} -
- - + }} + class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg" + disabled={setupSaving()} + > + Cancel + + +
-
-
- -); +
+ + ); }; export default AISettings; diff --git a/frontend-modern/src/components/Settings/AuditLogPanel.tsx b/frontend-modern/src/components/Settings/AuditLogPanel.tsx index eaf59cd59..118b54379 100644 --- a/frontend-modern/src/components/Settings/AuditLogPanel.tsx +++ b/frontend-modern/src/components/Settings/AuditLogPanel.tsx @@ -1,5 +1,5 @@ import { createSignal, Show, For, onMount, createMemo, onCleanup, createEffect } from 'solid-js'; -import { Shield, CheckCircle, XCircle, AlertTriangle, RefreshCw, Filter, Info, Play, X } from 'lucide-solid'; +import { Shield, CheckCircle, XCircle, RefreshCw, Filter, Info, Play, X } from 'lucide-solid'; import { showTooltip, hideTooltip } from '@/components/shared/Tooltip'; import Toggle from '@/components/shared/Toggle'; import { diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index bef4e472c..ed7428d8e 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -1036,6 +1036,7 @@ const Settings: Component = (props) => { icon: Component<{ class?: string; strokeWidth?: number }>; iconProps?: { strokeWidth?: number }; disabled?: boolean; + badge?: string; }[]; }[] = [ { @@ -1137,7 +1138,6 @@ const Settings: Component = (props) => { const flatTabs = tabGroups.flatMap((group) => group.items); - // Function to load nodes onMount(() => { loadLicenseStatus(); loadNodes(); @@ -1145,1387 +1145,1274 @@ const Settings: Component = (props) => { loadSecurityStatus(); runDiagnostics(); }); - try { - const nodesList = await NodesAPI.getNodes(); - // Merge temperature data from WebSocket state (if available) - // state is a store object, not a function - const stateNodes = state.nodes; - const nodesWithStatus = nodesList.map((node) => { - // Find matching node in state to get temperature data - // State uses a unified 'nodes' array for all node types - // Match nodes by ID or by name (handling .lan suffix variations) - const stateNode = stateNodes?.find((n) => { - // Try exact ID match first - if (n.id === node.id) return true; - // Try exact name match - if (n.name === node.name) return true; - // Try name with/without .lan suffix - const nodeNameBase = node.name.replace(/\.lan$/, ''); - const stateNameBase = n.name.replace(/\.lan$/, ''); - if (nodeNameBase === stateNameBase) return true; - // Also check if state node ID contains the config node name - if (n.id.includes(node.name) || node.name.includes(n.name)) return true; - return false; + + const loadNodes = async () => { + try { + const nodesList = await NodesAPI.getNodes(); + // Merge temperature data from WebSocket state (if available) + // state is a store object, not a function + const stateNodes = state.nodes; + const nodesWithStatus = nodesList.map((node) => { + // Find matching node in state to get temperature data + // State uses a unified 'nodes' array for all node types + // Match nodes by ID or by name (handling .lan suffix variations) + const stateNode = stateNodes?.find((n) => { + // Try exact ID match first + if (n.id === node.id) return true; + // Try exact name match + if (n.name === node.name) return true; + // Try name with/without .lan suffix + const nodeNameBase = node.name.replace(/\.lan$/, ''); + const stateNameBase = n.name.replace(/\.lan$/, ''); + if (nodeNameBase === stateNameBase) return true; + // Also check if state node ID contains the config node name + if (n.id.includes(node.name) || node.name.includes(n.name)) return true; + return false; + }); + + const mergedNode = { + ...node, + // Use the hasPassword/hasToken from the API if available, otherwise check local fields + hasPassword: node.hasPassword ?? !!node.password, + hasToken: node.hasToken ?? !!node.tokenValue, + status: node.status || ('pending' as const), + // Merge temperature data from state + temperature: stateNode?.temperature || node.temperature, + }; + + return mergedNode; }); - - const mergedNode = { - ...node, - // Use the hasPassword/hasToken from the API if available, otherwise check local fields - hasPassword: node.hasPassword ?? !!node.password, - hasToken: node.hasToken ?? !!node.tokenValue, - status: node.status || ('pending' as const), - // Merge temperature data from state - temperature: stateNode?.temperature || node.temperature, - }; - - return mergedNode; - }); - setNodes(nodesWithStatus); - } catch (error) { - logger.error('Failed to load nodes', error); - // If we get a 429 or network error, retry after a delay - if ( - error instanceof Error && - (error.message.includes('429') || error.message.includes('fetch')) - ) { - logger.info('Retrying node load after delay'); - setTimeout(() => loadNodes(), 3000); - } - } -}; - -// Function to load discovered nodes -const loadSecurityStatus = async () => { - setSecurityStatusLoading(true); - try { - const { apiFetch } = await import('@/utils/apiClient'); - const response = await apiFetch('/api/security/status'); - if (response.ok) { - const status = await response.json(); - logger.debug('Security status loaded', status); - setSecurityStatus(status); - } else { - logger.error('Failed to fetch security status', { status: response.status }); - } - } catch (err) { - logger.error('Failed to fetch security status', err); - } finally { - setSecurityStatusLoading(false); - } -}; - -createEffect(() => { - if (authDisabledByEnv() && showQuickSecuritySetup()) { - setShowQuickSecuritySetup(false); - } -}); - -const updateDiscoveredNodesFromServers = ( - servers: RawDiscoveredServer[] | undefined | null, - options: { merge?: boolean } = {}, -) => { - const { merge = false } = options; - - if (!servers || servers.length === 0) { - if (!merge) { - setDiscoveredNodes([]); - } - return; - } - - // Prepare sets of configured hosts and cluster member IPs to filter duplicates - const configuredHosts = new Set(); - const clusterMemberIPs = new Set(); - - nodes().forEach((n) => { - const cleanedHost = n.host.replace(/^https?:\/\//, '').replace(/:\d+$/, ''); - configuredHosts.add(cleanedHost.toLowerCase()); - - if ( - n.type === 'pve' && - 'isCluster' in n && - n.isCluster && - 'clusterEndpoints' in n && - n.clusterEndpoints - ) { - n.clusterEndpoints.forEach((endpoint: ClusterEndpoint) => { - if (endpoint.IP) { - clusterMemberIPs.add(endpoint.IP.toLowerCase()); - } - if (endpoint.Host) { - clusterMemberIPs.add(endpoint.Host.toLowerCase()); - } - }); - } - }); - - const recognizedTypes = ['pve', 'pbs', 'pmg'] as const; - type RecognizedType = (typeof recognizedTypes)[number]; - const isRecognizedType = (value: string): value is RecognizedType => - (recognizedTypes as readonly string[]).includes(value); - - const normalized = servers - .map((server): DiscoveredServer | null => { - const ip = (server.ip || '').trim(); - let type = (server.type || '').toLowerCase(); - const hostname = (server.hostname || server.name || '').trim(); - const version = (server.version || '').trim(); - const release = (server.release || '').trim(); - - if (!isRecognizedType(type)) { - const metadata = `${hostname} ${version} ${release}`.toLowerCase(); - if (metadata.includes('pmg') || metadata.includes('mail gateway')) { - type = 'pmg'; - } else if (metadata.includes('pbs') || metadata.includes('backup server')) { - type = 'pbs'; - } else if (metadata.includes('pve') || metadata.includes('virtual environment')) { - type = 'pve'; - } + setNodes(nodesWithStatus); + } catch (error) { + logger.error('Failed to load nodes', error); + // If we get a 429 or network error, retry after a delay + if ( + error instanceof Error && + (error.message.includes('429') || error.message.includes('fetch')) + ) { + logger.info('Retrying node load after delay'); + setTimeout(() => loadNodes(), 3000); } - - if (!ip || !isRecognizedType(type)) { - return null; - } - - const port = typeof server.port === 'number' ? server.port : type === 'pbs' ? 8007 : 8006; - - return { - ip, - port, - type, - version: version || 'Unknown', - hostname: hostname || undefined, - release: release || undefined, - }; - }) - .filter((server): server is DiscoveredServer => server !== null); - - const filtered = normalized.filter((server) => { - const serverIP = server.ip.toLowerCase(); - const serverHostname = server.hostname?.toLowerCase(); - - if ( - configuredHosts.has(serverIP) || - (serverHostname && configuredHosts.has(serverHostname)) - ) { - return false; } + }; - if ( - clusterMemberIPs.has(serverIP) || - (serverHostname && clusterMemberIPs.has(serverHostname)) - ) { - return false; - } - - return true; - }); - - if (merge) { - setDiscoveredNodes((prev) => { - const existingMap = new Map(prev.map((item) => [`${item.ip}:${item.port}`, item])); - filtered.forEach((server) => { - existingMap.set(`${server.ip}:${server.port}`, server); - }); - return Array.from(existingMap.values()); - }); - } else { - setDiscoveredNodes(filtered); - } - - setDiscoveryScanStatus((prev) => ({ - ...prev, - lastResultAt: Date.now(), - })); -}; - -const loadDiscoveredNodes = async () => { - try { - const { apiFetch } = await import('@/utils/apiClient'); - const response = await apiFetch('/api/discover'); - if (response.ok) { - const data = await response.json(); - if (Array.isArray(data.servers)) { - updateDiscoveredNodesFromServers(data.servers as RawDiscoveredServer[]); - setDiscoveryScanStatus((prev) => ({ - ...prev, - lastResultAt: typeof data.timestamp === 'number' ? data.timestamp : Date.now(), - errors: Array.isArray(data.errors) && data.errors.length > 0 ? data.errors : undefined, - })); + // Function to load discovered nodes + const loadSecurityStatus = async () => { + setSecurityStatusLoading(true); + try { + const { apiFetch } = await import('@/utils/apiClient'); + const response = await apiFetch('/api/security/status'); + if (response.ok) { + const status = await response.json(); + logger.debug('Security status loaded', status); + setSecurityStatus(status); } else { - updateDiscoveredNodesFromServers([]); - setDiscoveryScanStatus((prev) => ({ - ...prev, - lastResultAt: typeof data?.timestamp === 'number' ? data.timestamp : prev.lastResultAt, - errors: Array.isArray(data?.errors) && data.errors.length > 0 ? data.errors : undefined, - })); + logger.error('Failed to fetch security status', { status: response.status }); } + } catch (err) { + logger.error('Failed to fetch security status', err); + } finally { + setSecurityStatusLoading(false); } - } catch (error) { - logger.error('Failed to load discovered nodes', error); - } -}; + }; -const triggerDiscoveryScan = async (options: { quiet?: boolean } = {}) => { - const { quiet = false } = options; + createEffect(() => { + if (authDisabledByEnv() && showQuickSecuritySetup()) { + setShowQuickSecuritySetup(false); + } + }); - setDiscoveryScanStatus((prev) => ({ - ...prev, - scanning: true, - subnet: discoverySubnet() || prev.subnet, - lastScanStartedAt: Date.now(), - errors: undefined, - })); + const updateDiscoveredNodesFromServers = ( + servers: RawDiscoveredServer[] | undefined | null, + options: { merge?: boolean } = {}, + ) => { + const { merge = false } = options; - try { - const { apiFetch } = await import('@/utils/apiClient'); - const response = await apiFetch('/api/discover', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ subnet: discoverySubnet() || 'auto' }), + if (!servers || servers.length === 0) { + if (!merge) { + setDiscoveredNodes([]); + } + return; + } + + // Prepare sets of configured hosts and cluster member IPs to filter duplicates + const configuredHosts = new Set(); + const clusterMemberIPs = new Set(); + + nodes().forEach((n) => { + const cleanedHost = n.host.replace(/^https?:\/\//, '').replace(/:\d+$/, ''); + configuredHosts.add(cleanedHost.toLowerCase()); + + if ( + n.type === 'pve' && + 'isCluster' in n && + n.isCluster && + 'clusterEndpoints' in n && + n.clusterEndpoints + ) { + n.clusterEndpoints.forEach((endpoint: ClusterEndpoint) => { + if (endpoint.IP) { + clusterMemberIPs.add(endpoint.IP.toLowerCase()); + } + if (endpoint.Host) { + clusterMemberIPs.add(endpoint.Host.toLowerCase()); + } + }); + } }); - if (!response.ok) { - const message = await response.text(); - throw new Error(message || 'Discovery request failed'); + const recognizedTypes = ['pve', 'pbs', 'pmg'] as const; + type RecognizedType = (typeof recognizedTypes)[number]; + const isRecognizedType = (value: string): value is RecognizedType => + (recognizedTypes as readonly string[]).includes(value); + + const normalized = servers + .map((server): DiscoveredServer | null => { + const ip = (server.ip || '').trim(); + let type = (server.type || '').toLowerCase(); + const hostname = (server.hostname || server.name || '').trim(); + const version = (server.version || '').trim(); + const release = (server.release || '').trim(); + + if (!isRecognizedType(type)) { + const metadata = `${hostname} ${version} ${release}`.toLowerCase(); + if (metadata.includes('pmg') || metadata.includes('mail gateway')) { + type = 'pmg'; + } else if (metadata.includes('pbs') || metadata.includes('backup server')) { + type = 'pbs'; + } else if (metadata.includes('pve') || metadata.includes('virtual environment')) { + type = 'pve'; + } + } + + if (!ip || !isRecognizedType(type)) { + return null; + } + + const port = typeof server.port === 'number' ? server.port : type === 'pbs' ? 8007 : 8006; + + return { + ip, + port, + type, + version: version || 'Unknown', + hostname: hostname || undefined, + release: release || undefined, + }; + }) + .filter((server): server is DiscoveredServer => server !== null); + + const filtered = normalized.filter((server) => { + const serverIP = server.ip.toLowerCase(); + const serverHostname = server.hostname?.toLowerCase(); + + if ( + configuredHosts.has(serverIP) || + (serverHostname && configuredHosts.has(serverHostname)) + ) { + return false; + } + + if ( + clusterMemberIPs.has(serverIP) || + (serverHostname && clusterMemberIPs.has(serverHostname)) + ) { + return false; + } + + return true; + }); + + if (merge) { + setDiscoveredNodes((prev) => { + const existingMap = new Map(prev.map((item) => [`${item.ip}:${item.port}`, item])); + filtered.forEach((server) => { + existingMap.set(`${server.ip}:${server.port}`, server); + }); + return Array.from(existingMap.values()); + }); + } else { + setDiscoveredNodes(filtered); } - if (!quiet) { - notificationStore.info('Discovery scan started', 2000); - } - } catch (error) { - logger.error('Failed to start discovery scan', error); - notificationStore.error('Failed to start discovery scan'); setDiscoveryScanStatus((prev) => ({ ...prev, - scanning: false, + lastResultAt: Date.now(), })); - } -}; + }; -const handleDiscoveryEnabledChange = async (enabled: boolean): Promise => { - if (envOverrides().discoveryEnabled || savingDiscoverySettings()) { - return false; - } - - const previousEnabled = discoveryEnabled(); - const previousSubnet = discoverySubnet(); - let subnetToSend = discoverySubnet(); - - if (enabled) { - if (discoveryMode() === 'custom') { - const trimmedDraft = discoverySubnetDraft().trim(); - if (!trimmedDraft) { - setDiscoverySubnetError('Enter at least one subnet before enabling discovery'); - notificationStore.error('Enter at least one subnet before enabling discovery'); - return false; + const loadDiscoveredNodes = async () => { + try { + const { apiFetch } = await import('@/utils/apiClient'); + const response = await apiFetch('/api/discover'); + if (response.ok) { + const data = await response.json(); + if (Array.isArray(data.servers)) { + updateDiscoveredNodesFromServers(data.servers as RawDiscoveredServer[]); + setDiscoveryScanStatus((prev) => ({ + ...prev, + lastResultAt: typeof data.timestamp === 'number' ? data.timestamp : Date.now(), + errors: Array.isArray(data.errors) && data.errors.length > 0 ? data.errors : undefined, + })); + } else { + updateDiscoveredNodesFromServers([]); + setDiscoveryScanStatus((prev) => ({ + ...prev, + lastResultAt: typeof data?.timestamp === 'number' ? data.timestamp : prev.lastResultAt, + errors: Array.isArray(data?.errors) && data.errors.length > 0 ? data.errors : undefined, + })); + } } - if (!isValidCIDR(trimmedDraft)) { - setDiscoverySubnetError( - 'Use CIDR format such as 192.168.1.0/24 (comma-separated for multiple)', - ); - notificationStore.error('Enter valid CIDR subnet values before enabling discovery'); - return false; + } catch (error) { + logger.error('Failed to load discovered nodes', error); + } + }; + + const triggerDiscoveryScan = async (options: { quiet?: boolean } = {}) => { + const { quiet = false } = options; + + setDiscoveryScanStatus((prev) => ({ + ...prev, + scanning: true, + subnet: discoverySubnet() || prev.subnet, + lastScanStartedAt: Date.now(), + errors: undefined, + })); + + try { + const { apiFetch } = await import('@/utils/apiClient'); + const response = await apiFetch('/api/discover', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ subnet: discoverySubnet() || 'auto' }), + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error(message || 'Discovery request failed'); } - const normalizedDraft = normalizeSubnetList(trimmedDraft); - setDiscoverySubnetDraft(normalizedDraft); - setDiscoverySubnetError(undefined); - subnetToSend = normalizedDraft; - } else { - subnetToSend = 'auto'; - setDiscoverySubnetError(undefined); - } - } - setDiscoveryEnabled(enabled); - setSavingDiscoverySettings(true); - - try { - await SettingsAPI.updateSystemSettings({ - discoveryEnabled: enabled, - discoverySubnet: subnetToSend, - }); - applySavedDiscoverySubnet(subnetToSend); - if (enabled && subnetToSend !== 'auto') { - setLastCustomSubnet(subnetToSend); - } - - if (enabled) { - await triggerDiscoveryScan({ quiet: true }); - notificationStore.success('Discovery enabled — scanning network...', 2000); - } else { - notificationStore.info('Discovery disabled', 2000); + if (!quiet) { + notificationStore.info('Discovery scan started', 2000); + } + } catch (error) { + logger.error('Failed to start discovery scan', error); + notificationStore.error('Failed to start discovery scan'); setDiscoveryScanStatus((prev) => ({ ...prev, scanning: false, })); } + }; - return true; - } catch (error) { - logger.error('Failed to update discovery setting', error); - notificationStore.error('Failed to update discovery setting'); - setDiscoveryEnabled(previousEnabled); - applySavedDiscoverySubnet(previousSubnet); - return false; - } finally { - setSavingDiscoverySettings(false); - await loadDiscoveredNodes(); - } -}; - -const commitDiscoverySubnet = async (rawValue: string): Promise => { - if (envOverrides().discoverySubnet) { - return false; - } - - const value = rawValue.trim(); - if (!value) { - setDiscoverySubnetError('Enter at least one subnet in CIDR format (e.g., 192.168.1.0/24)'); - return false; - } - if (!isValidCIDR(value)) { - setDiscoverySubnetError( - 'Use CIDR format such as 192.168.1.0/24 (comma-separated for multiple)', - ); - return false; - } - - const normalizedValue = normalizeSubnetList(value); - if (!normalizedValue) { - setDiscoverySubnetError('Enter at least one valid subnet in CIDR format'); - return false; - } - - const previousSubnet = discoverySubnet(); - const previousNormalized = - previousSubnet.toLowerCase() === 'auto' ? '' : normalizeSubnetList(previousSubnet); - - if (normalizedValue === previousNormalized) { - setDiscoverySubnetDraft(normalizedValue); - setDiscoverySubnetError(undefined); - setLastCustomSubnet(normalizedValue); - return true; - } - - setSavingDiscoverySettings(true); - - try { - setDiscoverySubnetError(undefined); - await SettingsAPI.updateSystemSettings({ - discoveryEnabled: discoveryEnabled(), - discoverySubnet: normalizedValue, - }); - setLastCustomSubnet(normalizedValue); - applySavedDiscoverySubnet(normalizedValue); - if (discoveryEnabled()) { - await triggerDiscoveryScan({ quiet: true }); - notificationStore.success('Discovery subnet updated — scanning network...', 2000); - } else { - notificationStore.success('Discovery subnet saved', 2000); + const handleDiscoveryEnabledChange = async (enabled: boolean): Promise => { + if (envOverrides().discoveryEnabled || savingDiscoverySettings()) { + return false; } - return true; - } catch (error) { - logger.error('Failed to update discovery subnet', error); - notificationStore.error('Failed to update discovery subnet'); - applySavedDiscoverySubnet(previousSubnet); - setDiscoverySubnetDraft(previousSubnet === 'auto' ? '' : normalizeSubnetList(previousSubnet)); - return false; - } finally { - setDiscoverySubnetError(undefined); - setSavingDiscoverySettings(false); - await loadDiscoveredNodes(); - } -}; -const handleTemperatureMonitoringChange = async (enabled: boolean): Promise => { - if (temperatureMonitoringLocked() || savingTemperatureSetting()) { - return; - } - - const previous = temperatureMonitoringEnabled(); - setTemperatureMonitoringEnabled(enabled); - setSavingTemperatureSetting(true); - - try { - await SettingsAPI.updateSystemSettings({ temperatureMonitoringEnabled: enabled }); - if (enabled) { - notificationStore.success('Temperature monitoring enabled', 2000); - } else { - notificationStore.info('Temperature monitoring disabled', 2000); - } - } catch (error) { - logger.error('Failed to update temperature monitoring setting', error); - notificationStore.error( - error instanceof Error - ? error.message - : 'Failed to update temperature monitoring setting', - ); - setTemperatureMonitoringEnabled(previous); - } finally { - setSavingTemperatureSetting(false); - } -}; - -const handleNodeTemperatureMonitoringChange = async (nodeId: string, enabled: boolean | null): Promise => { - if (savingTemperatureSetting()) { - return; - } - - const node = nodes().find((n) => n.id === nodeId); - if (!node) { - return; - } - - const previous = node.temperatureMonitoringEnabled; - setSavingTemperatureSetting(true); - - // Update local state optimistically - setNodes( - nodes().map((n) => (n.id === nodeId ? { ...n, temperatureMonitoringEnabled: enabled } : n)), - ); - - // Also update editingNode if this is the node being edited - if (editingNode()?.id === nodeId) { - setEditingNode({ ...editingNode()!, temperatureMonitoringEnabled: enabled }); - } - - try { - await NodesAPI.updateNode(nodeId, { temperatureMonitoringEnabled: enabled } as any); - if (enabled === true) { - notificationStore.success('Temperature monitoring enabled for this node', 2000); - } else if (enabled === false) { - notificationStore.info('Temperature monitoring disabled for this node', 2000); - } else { - notificationStore.info('Using global temperature monitoring setting', 2000); - } - } catch (error) { - logger.error('Failed to update node temperature monitoring setting', error); - notificationStore.error( - error instanceof Error - ? error.message - : 'Failed to update temperature monitoring setting', - ); - // Revert on error - setNodes( - nodes().map((n) => (n.id === nodeId ? { ...n, temperatureMonitoringEnabled: previous } : n)), - ); - // Also revert editingNode - if (editingNode()?.id === nodeId) { - setEditingNode({ ...editingNode()!, temperatureMonitoringEnabled: previous }); - } - } finally { - setSavingTemperatureSetting(false); - } -}; - -const handleDiscoveryModeChange = async (mode: 'auto' | 'custom') => { - if (envOverrides().discoverySubnet || savingDiscoverySettings()) { - return; - } - if (mode === discoveryMode()) { - return; - } - - if (mode === 'auto') { + const previousEnabled = discoveryEnabled(); const previousSubnet = discoverySubnet(); - setDiscoveryMode('auto'); - setDiscoverySubnetDraft(''); - setDiscoverySubnetError(undefined); + let subnetToSend = discoverySubnet(); + + if (enabled) { + if (discoveryMode() === 'custom') { + const trimmedDraft = discoverySubnetDraft().trim(); + if (!trimmedDraft) { + setDiscoverySubnetError('Enter at least one subnet before enabling discovery'); + notificationStore.error('Enter at least one subnet before enabling discovery'); + return false; + } + if (!isValidCIDR(trimmedDraft)) { + setDiscoverySubnetError( + 'Use CIDR format such as 192.168.1.0/24 (comma-separated for multiple)', + ); + notificationStore.error('Enter valid CIDR subnet values before enabling discovery'); + return false; + } + const normalizedDraft = normalizeSubnetList(trimmedDraft); + setDiscoverySubnetDraft(normalizedDraft); + setDiscoverySubnetError(undefined); + subnetToSend = normalizedDraft; + } else { + subnetToSend = 'auto'; + setDiscoverySubnetError(undefined); + } + } + + setDiscoveryEnabled(enabled); setSavingDiscoverySettings(true); + try { await SettingsAPI.updateSystemSettings({ - discoveryEnabled: discoveryEnabled(), - discoverySubnet: 'auto', + discoveryEnabled: enabled, + discoverySubnet: subnetToSend, }); - applySavedDiscoverySubnet('auto'); - if (discoveryEnabled()) { - await triggerDiscoveryScan({ quiet: true }); + applySavedDiscoverySubnet(subnetToSend); + if (enabled && subnetToSend !== 'auto') { + setLastCustomSubnet(subnetToSend); } - notificationStore.info( - 'Auto discovery scans each network phase. Large networks may take longer.', - 4000, - ); + + if (enabled) { + await triggerDiscoveryScan({ quiet: true }); + notificationStore.success('Discovery enabled — scanning network...', 2000); + } else { + notificationStore.info('Discovery disabled', 2000); + setDiscoveryScanStatus((prev) => ({ + ...prev, + scanning: false, + })); + } + + return true; } catch (error) { - logger.error('Failed to update discovery subnet', error); - notificationStore.error('Failed to update discovery subnet'); + logger.error('Failed to update discovery setting', error); + notificationStore.error('Failed to update discovery setting'); + setDiscoveryEnabled(previousEnabled); applySavedDiscoverySubnet(previousSubnet); + return false; } finally { setSavingDiscoverySettings(false); await loadDiscoveredNodes(); } - return; - } + }; - setDiscoveryMode('custom'); - const rawDraft = discoverySubnet() !== 'auto' ? discoverySubnet() : lastCustomSubnet() || ''; - const normalizedDraft = normalizeSubnetList(rawDraft); - setDiscoverySubnetDraft(normalizedDraft); - setDiscoverySubnetError(undefined); - queueMicrotask(() => { - discoverySubnetInputRef?.focus(); - discoverySubnetInputRef?.select(); - }); -}; - -// Load nodes and system settings on mount -onMount(async () => { - // Subscribe to events - const unsubscribeAutoRegister = eventBus.on('node_auto_registered', () => { - // Close any open modals - setShowNodeModal(false); - setEditingNode(null); - // Reload nodes - loadNodes(); - loadDiscoveredNodes(); - }); - - const unsubscribeRefresh = eventBus.on('refresh_nodes', () => { - loadNodes(); - }); - - const unsubscribeDiscovery = eventBus.on('discovery_updated', (data) => { - if (!data) { - updateDiscoveredNodesFromServers([]); - setDiscoveryScanStatus((prev) => ({ - ...prev, - scanning: false, - })); - return; + const commitDiscoverySubnet = async (rawValue: string): Promise => { + if (envOverrides().discoverySubnet) { + return false; } - if (Array.isArray(data.servers)) { - updateDiscoveredNodesFromServers(data.servers as RawDiscoveredServer[], { - merge: !!data.immediate, - }); - setDiscoveryScanStatus((prev) => ({ - ...prev, - scanning: data.scanning ?? prev.scanning, - lastResultAt: data.timestamp ?? Date.now(), - errors: Array.isArray(data.errors) && data.errors.length > 0 ? data.errors : undefined, - })); - } else if (!data.immediate) { - // Ensure we clear stale results when the update explicitly reports no servers - updateDiscoveredNodesFromServers([]); - setDiscoveryScanStatus((prev) => ({ - ...prev, - scanning: data.scanning ?? prev.scanning, - lastResultAt: data.timestamp ?? prev.lastResultAt, - errors: Array.isArray(data.errors) && data.errors.length > 0 ? data.errors : undefined, - })); - } else { - setDiscoveryScanStatus((prev) => ({ - ...prev, - scanning: data.scanning ?? prev.scanning, - errors: Array.isArray(data.errors) && data.errors.length > 0 ? data.errors : undefined, - })); + const value = rawValue.trim(); + if (!value) { + setDiscoverySubnetError('Enter at least one subnet in CIDR format (e.g., 192.168.1.0/24)'); + return false; } - }); - - const unsubscribeDiscoveryStatus = eventBus.on('discovery_status', (data) => { - if (!data) { - setDiscoveryScanStatus((prev) => ({ - ...prev, - scanning: false, - })); - return; - } - - setDiscoveryScanStatus((prev) => ({ - ...prev, - scanning: !!data.scanning, - subnet: data.subnet || prev.subnet, - lastScanStartedAt: data.scanning ? (data.timestamp ?? Date.now()) : prev.lastScanStartedAt, - lastResultAt: !data.scanning && data.timestamp ? data.timestamp : prev.lastResultAt, - })); - - if (typeof data.subnet === 'string' && data.subnet !== discoverySubnet()) { - applySavedDiscoverySubnet(data.subnet); - } - }); - - // Poll for node updates when modal is open - let pollInterval: ReturnType | undefined; - createEffect(() => { - // Clear any existing interval first - if (pollInterval) { - clearInterval(pollInterval); - pollInterval = undefined; - } - - if (showNodeModal()) { - // Start polling every 3 seconds when modal is open - pollInterval = setInterval(() => { - loadNodes(); - loadDiscoveredNodes(); - }, 3000); - } - }); - - // Poll for discovered nodes every 30 seconds - const discoveryInterval = setInterval(() => { - loadDiscoveredNodes(); - }, 30000); - - // Clean up on unmount - onCleanup(() => { - unsubscribeAutoRegister(); - unsubscribeRefresh(); - unsubscribeDiscovery(); - unsubscribeDiscoveryStatus(); - if (pollInterval) { - clearInterval(pollInterval); - } - clearInterval(discoveryInterval); - }); - - try { - // Load data with small delays to prevent rate limit bursts - // Load security status first as it's lightweight - await loadSecurityStatus(); - - // Small delay to prevent burst - await new Promise((resolve) => setTimeout(resolve, 50)); - - // Load nodes - await loadNodes(); - - // Another small delay - await new Promise((resolve) => setTimeout(resolve, 50)); - - // Load discovered nodes - await loadDiscoveredNodes(); - - // Load system settings - try { - const systemSettings = await SettingsAPI.getSystemSettings(); - const rawPVESecs = - typeof systemSettings.pvePollingInterval === 'number' - ? Math.round(systemSettings.pvePollingInterval) - : PVE_POLLING_MIN_SECONDS; - const clampedPVESecs = Math.min( - PVE_POLLING_MAX_SECONDS, - Math.max(PVE_POLLING_MIN_SECONDS, rawPVESecs), + if (!isValidCIDR(value)) { + setDiscoverySubnetError( + 'Use CIDR format such as 192.168.1.0/24 (comma-separated for multiple)', ); - setPVEPollingInterval(clampedPVESecs); - const presetMatch = PVE_POLLING_PRESETS.find((opt) => opt.value === clampedPVESecs); - if (presetMatch) { - setPVEPollingSelection(presetMatch.value); - } else { - setPVEPollingSelection('custom'); - setPVEPollingCustomSeconds(clampedPVESecs); - } - setAllowedOrigins(systemSettings.allowedOrigins || '*'); - // Connection timeout is backend-only - // Load discovery settings (default to false when unset) - setDiscoveryEnabled(systemSettings.discoveryEnabled ?? false); - applySavedDiscoverySubnet(systemSettings.discoverySubnet); - // Load embedding settings - setAllowEmbedding(systemSettings.allowEmbedding ?? false); - setAllowedEmbedOrigins(systemSettings.allowedEmbedOrigins || ''); - // Load webhook security settings - setWebhookAllowedPrivateCIDRs(systemSettings.webhookAllowedPrivateCIDRs || ''); - // Load public URL for notifications - setPublicURL(systemSettings.publicURL || ''); - setTemperatureMonitoringEnabled( - typeof systemSettings.temperatureMonitoringEnabled === 'boolean' - ? systemSettings.temperatureMonitoringEnabled - : true, - ); - // Load hideLocalLogin setting - setHideLocalLogin(systemSettings.hideLocalLogin ?? false); - - // Load Docker update actions setting - setDisableDockerUpdateActions(systemSettings.disableDockerUpdateActions ?? false); - - // Backup polling controls - if (typeof systemSettings.backupPollingEnabled === 'boolean') { - setBackupPollingEnabled(systemSettings.backupPollingEnabled); - } else { - setBackupPollingEnabled(true); - } - const intervalSeconds = - typeof systemSettings.backupPollingInterval === 'number' - ? Math.max(0, Math.floor(systemSettings.backupPollingInterval)) - : 0; - setBackupPollingInterval(intervalSeconds); - if (intervalSeconds > 0) { - setBackupPollingCustomMinutes(Math.max(1, Math.round(intervalSeconds / 60))); - } - // Determine if the loaded interval is a custom value - const isPresetInterval = BACKUP_INTERVAL_OPTIONS.some((opt) => opt.value === intervalSeconds); - setBackupPollingUseCustom(!isPresetInterval && intervalSeconds > 0); - // Load auto-update settings - setAutoUpdateEnabled(systemSettings.autoUpdateEnabled || false); - setAutoUpdateCheckInterval(systemSettings.autoUpdateCheckInterval || 24); - setAutoUpdateTime(systemSettings.autoUpdateTime || '03:00'); - if (systemSettings.updateChannel) { - setUpdateChannel(systemSettings.updateChannel as 'stable' | 'rc'); - } - // Track environment variable overrides - if (systemSettings.envOverrides) { - setEnvOverrides(systemSettings.envOverrides); - } - } catch (error) { - logger.error('Failed to load settings', error); + return false; } - // Load version information + const normalizedValue = normalizeSubnetList(value); + if (!normalizedValue) { + setDiscoverySubnetError('Enter at least one valid subnet in CIDR format'); + return false; + } + + const previousSubnet = discoverySubnet(); + const previousNormalized = + previousSubnet.toLowerCase() === 'auto' ? '' : normalizeSubnetList(previousSubnet); + + if (normalizedValue === previousNormalized) { + setDiscoverySubnetDraft(normalizedValue); + setDiscoverySubnetError(undefined); + setLastCustomSubnet(normalizedValue); + return true; + } + + setSavingDiscoverySettings(true); + try { - const version = await UpdatesAPI.getVersion(); - setVersionInfo(version); - // Also set it in the store so it's available globally - await updateStore.checkForUpdates(); // This will load version info too - - // Fetch update info and plan from store - const storeInfo = updateStore.updateInfo(); - if (storeInfo) { - setUpdateInfo(storeInfo); - // Fetch update plan if update is available - if (storeInfo.available && storeInfo.latestVersion) { - try { - const plan = await UpdatesAPI.getUpdatePlan(storeInfo.latestVersion); - setUpdatePlan(plan); - } catch (planError) { - logger.warn('Failed to fetch update plan on load', planError); - } - } - } - - // Only use version.channel as fallback if user hasn't configured a preference - // The user's saved updateChannel preference should take priority - // Check the signal value since systemSettings is scoped to the previous try block - if (version.channel && !updateChannel()) { - setUpdateChannel(version.channel as 'stable' | 'rc'); - } - } catch (error) { - logger.error('Failed to load version', error); - } - } catch (error) { - logger.error('Failed to load configuration', error); - } finally { - // Mark initial load as complete even if there were errors - setInitialLoadComplete(true); - } -}); - -// Re-merge temperature data from WebSocket state when it updates -createEffect( - on( - () => state.nodes, - (stateNodes) => { - const currentNodes = nodes(); - - // Only run if we have nodes loaded and state has data - if (stateNodes && stateNodes.length > 0 && currentNodes.length > 0) { - const updatedNodes = currentNodes.map((node) => { - // Match nodes by ID or by name (handling .lan suffix variations) - const stateNode = stateNodes.find((n) => { - // Try exact ID match first - if (n.id === node.id) return true; - // Try exact name match - if (n.name === node.name) return true; - // Try name with/without .lan suffix - const nodeNameBase = node.name.replace(/\.lan$/, ''); - const stateNameBase = n.name.replace(/\.lan$/, ''); - if (nodeNameBase === stateNameBase) return true; - // Also check if state node ID contains the config node name - if (n.id.includes(node.name) || node.name.includes(n.name)) return true; - return false; - }); - - // Merge temperature data from state if available - if (stateNode?.temperature) { - return { ...node, temperature: stateNode.temperature }; - } - return node; - }); - setNodes(updatedNodes); - } - }, - ), -); - -const saveSettings = async () => { - try { - if ( - activeTab() === 'system-general' || - activeTab() === 'system-network' || - activeTab() === 'system-updates' || - activeTab() === 'system-backups' - ) { - // Save system settings using typed API + setDiscoverySubnetError(undefined); await SettingsAPI.updateSystemSettings({ - pvePollingInterval: pvePollingInterval(), - allowedOrigins: allowedOrigins(), - // Connection timeout is backend-only - // Discovery settings are saved immediately on toggle - updateChannel: updateChannel(), - autoUpdateEnabled: autoUpdateEnabled(), - autoUpdateCheckInterval: autoUpdateCheckInterval(), - autoUpdateTime: autoUpdateTime(), - backupPollingEnabled: backupPollingEnabled(), - backupPollingInterval: backupPollingInterval(), - allowEmbedding: allowEmbedding(), - allowedEmbedOrigins: allowedEmbedOrigins(), - webhookAllowedPrivateCIDRs: webhookAllowedPrivateCIDRs(), - publicURL: publicURL(), + discoveryEnabled: discoveryEnabled(), + discoverySubnet: normalizedValue, }); + setLastCustomSubnet(normalizedValue); + applySavedDiscoverySubnet(normalizedValue); + if (discoveryEnabled()) { + await triggerDiscoveryScan({ quiet: true }); + notificationStore.success('Discovery subnet updated — scanning network...', 2000); + } else { + notificationStore.success('Discovery subnet saved', 2000); + } + return true; + } catch (error) { + logger.error('Failed to update discovery subnet', error); + notificationStore.error('Failed to update discovery subnet'); + applySavedDiscoverySubnet(previousSubnet); + setDiscoverySubnetDraft(previousSubnet === 'auto' ? '' : normalizeSubnetList(previousSubnet)); + return false; + } finally { + setDiscoverySubnetError(undefined); + setSavingDiscoverySettings(false); + await loadDiscoveredNodes(); + } + }; + + const handleTemperatureMonitoringChange = async (enabled: boolean): Promise => { + if (temperatureMonitoringLocked() || savingTemperatureSetting()) { + return; } - notificationStore.success('Settings saved successfully. Service restart may be required for port changes.'); - setHasUnsavedChanges(false); + const previous = temperatureMonitoringEnabled(); + setTemperatureMonitoringEnabled(enabled); + setSavingTemperatureSetting(true); - // Reload the page after a short delay to ensure the new settings are applied - setTimeout(() => { - window.location.reload(); - }, 3000); - } catch (error) { - notificationStore.error(error instanceof Error ? error.message : 'Failed to save settings'); - } -}; + try { + await SettingsAPI.updateSystemSettings({ temperatureMonitoringEnabled: enabled }); + if (enabled) { + notificationStore.success('Temperature monitoring enabled', 2000); + } else { + notificationStore.info('Temperature monitoring disabled', 2000); + } + } catch (error) { + logger.error('Failed to update temperature monitoring setting', error); + notificationStore.error( + error instanceof Error + ? error.message + : 'Failed to update temperature monitoring setting', + ); + setTemperatureMonitoringEnabled(previous); + } finally { + setSavingTemperatureSetting(false); + } + }; -const nodePendingDeleteLabel = () => { - const node = nodePendingDelete(); - if (!node) return ''; - return node.displayName || node.name || node.host || node.id; -}; + const handleNodeTemperatureMonitoringChange = async (nodeId: string, enabled: boolean | null): Promise => { + if (savingTemperatureSetting()) { + return; + } -const nodePendingDeleteHost = () => nodePendingDelete()?.host || ''; -const nodePendingDeleteType = () => nodePendingDelete()?.type || ''; -const nodePendingDeleteTypeLabel = () => { - switch (nodePendingDeleteType()) { - case 'pve': - return 'Proxmox VE node'; - case 'pbs': - return 'Proxmox Backup Server'; - case 'pmg': - return 'Proxmox Mail Gateway'; - default: - return 'Pulse node'; - } -}; - -const requestDeleteNode = (node: NodeConfigWithStatus) => { - setNodePendingDelete(node); - setShowDeleteNodeModal(true); -}; - -const cancelDeleteNode = () => { - if (deleteNodeLoading()) return; - setShowDeleteNodeModal(false); - setNodePendingDelete(null); -}; - -const deleteNode = async () => { - const pending = nodePendingDelete(); - if (!pending) return; - setDeleteNodeLoading(true); - try { - await NodesAPI.deleteNode(pending.id); - setNodes(nodes().filter((n) => n.id !== pending.id)); - const label = pending.displayName || pending.name || pending.host || pending.id; - notificationStore.success(`${label} removed successfully`); - } catch (error) { - notificationStore.error(error instanceof Error ? error.message : 'Failed to delete node'); - } finally { - setDeleteNodeLoading(false); - setShowDeleteNodeModal(false); - setNodePendingDelete(null); - } -}; - -const testNodeConnection = async (nodeId: string) => { - try { const node = nodes().find((n) => n.id === nodeId); if (!node) { - throw new Error('Node not found'); + return; } - // Use the existing node test endpoint which uses stored credentials - const result = await NodesAPI.testExistingNode(nodeId); - if (result.status === 'success') { - // Check for warnings in the response - if (result.warnings && Array.isArray(result.warnings) && result.warnings.length > 0) { - const warningMessage = result.message + '\n\nWarnings:\n' + result.warnings.map((w: string) => '• ' + w).join('\n'); - notificationStore.warning(warningMessage); + const previous = node.temperatureMonitoringEnabled; + setSavingTemperatureSetting(true); + + // Update local state optimistically + setNodes( + nodes().map((n) => (n.id === nodeId ? { ...n, temperatureMonitoringEnabled: enabled } : n)), + ); + + // Also update editingNode if this is the node being edited + if (editingNode()?.id === nodeId) { + setEditingNode({ ...editingNode()!, temperatureMonitoringEnabled: enabled }); + } + + try { + await NodesAPI.updateNode(nodeId, { temperatureMonitoringEnabled: enabled } as any); + if (enabled === true) { + notificationStore.success('Temperature monitoring enabled for this node', 2000); + } else if (enabled === false) { + notificationStore.info('Temperature monitoring disabled for this node', 2000); } else { - notificationStore.success(result.message || 'Connection successful'); + notificationStore.info('Using global temperature monitoring setting', 2000); } - } else { - throw new Error(result.message || 'Connection failed'); - } - } catch (error) { - notificationStore.error(error instanceof Error ? error.message : 'Connection test failed'); - } -}; - -const refreshClusterNodes = async (nodeId: string) => { - try { - notificationStore.info('Refreshing cluster membership...', 2000); - const result = await NodesAPI.refreshClusterNodes(nodeId); - if (result.status === 'success') { - if (result.nodesAdded && result.nodesAdded > 0) { - notificationStore.success(`Found ${result.nodesAdded} new node(s) in cluster "${result.clusterName}"`); - } else { - notificationStore.success(`Cluster "${result.clusterName}" membership verified (${result.newNodeCount} nodes)`); + } catch (error) { + logger.error('Failed to update node temperature monitoring setting', error); + notificationStore.error( + error instanceof Error + ? error.message + : 'Failed to update temperature monitoring setting', + ); + // Revert on error + setNodes( + nodes().map((n) => (n.id === nodeId ? { ...n, temperatureMonitoringEnabled: previous } : n)), + ); + // Also revert editingNode + if (editingNode()?.id === nodeId) { + setEditingNode({ ...editingNode()!, temperatureMonitoringEnabled: previous }); } - // Refresh nodes list to show updated cluster info - await loadNodes(); - } else { - throw new Error('Failed to refresh cluster'); + } finally { + setSavingTemperatureSetting(false); } - } catch (error) { - notificationStore.error(error instanceof Error ? error.message : 'Failed to refresh cluster membership'); - } -}; + }; -const checkForUpdates = async () => { - setCheckingForUpdates(true); - try { - // Force check with current channel selection - await updateStore.checkForUpdates(true); - const info = updateStore.updateInfo(); - setUpdateInfo(info); + const handleDiscoveryModeChange = async (mode: 'auto' | 'custom') => { + if (envOverrides().discoverySubnet || savingDiscoverySettings()) { + return; + } + if (mode === discoveryMode()) { + return; + } - // Fetch update plan if update is available - if (info?.available && info.latestVersion) { + if (mode === 'auto') { + const previousSubnet = discoverySubnet(); + setDiscoveryMode('auto'); + setDiscoverySubnetDraft(''); + setDiscoverySubnetError(undefined); + setSavingDiscoverySettings(true); try { - const plan = await UpdatesAPI.getUpdatePlan(info.latestVersion); - setUpdatePlan(plan); - } catch (planError) { - logger.warn('Failed to fetch update plan', planError); - setUpdatePlan(null); + await SettingsAPI.updateSystemSettings({ + discoveryEnabled: discoveryEnabled(), + discoverySubnet: 'auto', + }); + applySavedDiscoverySubnet('auto'); + if (discoveryEnabled()) { + await triggerDiscoveryScan({ quiet: true }); + } + notificationStore.info( + 'Auto discovery scans each network phase. Large networks may take longer.', + 4000, + ); + } catch (error) { + logger.error('Failed to update discovery subnet', error); + notificationStore.error('Failed to update discovery subnet'); + applySavedDiscoverySubnet(previousSubnet); + } finally { + setSavingDiscoverySettings(false); + await loadDiscoveredNodes(); } - } else { - setUpdatePlan(null); + return; } - // If update was dismissed, clear it so user can see it again - if (info?.available && updateStore.isDismissed()) { - updateStore.clearDismissed(); - } + setDiscoveryMode('custom'); + const rawDraft = discoverySubnet() !== 'auto' ? discoverySubnet() : lastCustomSubnet() || ''; + const normalizedDraft = normalizeSubnetList(rawDraft); + setDiscoverySubnetDraft(normalizedDraft); + setDiscoverySubnetError(undefined); + queueMicrotask(() => { + discoverySubnetInputRef?.focus(); + discoverySubnetInputRef?.select(); + }); + }; - if (!info?.available) { - notificationStore.success('You are running the latest version'); - } - } catch (error) { - notificationStore.error('Failed to check for updates'); - logger.error('Update check error', error); - } finally { - setCheckingForUpdates(false); - } -}; - -// Handle install update from settings panel -const handleInstallUpdate = () => { - setShowUpdateConfirmation(true); -}; - -const handleConfirmUpdate = async () => { - const info = updateInfo(); - if (!info?.downloadUrl) return; - - setIsInstallingUpdate(true); - try { - await UpdatesAPI.applyUpdate(info.downloadUrl); - // Close confirmation - GlobalUpdateProgressWatcher will auto-open the progress modal - setShowUpdateConfirmation(false); - } catch (error) { - logger.error('Failed to start update', error); - notificationStore.error('Failed to start update. Please try again.'); - } finally { - setIsInstallingUpdate(false); - } -}; - -const handleExport = async () => { - if (!exportPassphrase()) { - const hasAuth = securityStatus()?.hasAuthentication; - notificationStore.error( - hasAuth - ? useCustomPassphrase() - ? 'Please enter a passphrase' - : 'Please enter your password' - : 'Please enter a passphrase', - ); - return; - } - - // Backend requires at least 12 characters for encryption security - if (exportPassphrase().length < 12) { - const hasAuth = securityStatus()?.hasAuthentication; - notificationStore.error( - hasAuth && !useCustomPassphrase() - ? 'Your password must be at least 12 characters. Please use a custom passphrase instead.' - : 'Passphrase must be at least 12 characters long', - ); - return; - } - - // Only check for API token if user is not authenticated via password - // If user is logged in with password, session auth is sufficient - const hasPasswordAuth = securityStatus()?.hasAuthentication; - if (!hasPasswordAuth && securityStatus()?.apiTokenConfigured && !getApiClientToken()) { - setApiTokenModalSource('export'); - setShowApiTokenModal(true); - return; - } - - try { - // Get CSRF token from cookie - const csrfCookie = document.cookie - .split('; ') - .find((row) => row.startsWith('pulse_csrf=')); - const csrfToken = csrfCookie - ? decodeURIComponent(csrfCookie.split('=').slice(1).join('=')) - : undefined; - - const headers: HeadersInit = { - 'Content-Type': 'application/json', - }; - - // Add CSRF token if available - if (csrfToken) { - headers['X-CSRF-Token'] = csrfToken; - } - - // Add API token if configured - const apiToken = getApiClientToken(); - if (apiToken) { - headers['X-API-Token'] = apiToken; - } - - const data = await apiFetchJSON('/api/config/export', { - method: 'POST', - body: JSON.stringify({ passphrase: exportPassphrase() }), + // Load nodes and system settings on mount + onMount(async () => { + // Subscribe to events + const unsubscribeAutoRegister = eventBus.on('node_auto_registered', () => { + // Close any open modals + setShowNodeModal(false); + setEditingNode(null); + // Reload nodes + loadNodes(); + loadDiscoveredNodes(); }); - // Create and download file - const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `pulse-config-${new Date().toISOString().split('T')[0]}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + const unsubscribeRefresh = eventBus.on('refresh_nodes', () => { + loadNodes(); + }); - notificationStore.success('Configuration exported successfully'); - setShowExportDialog(false); - setExportPassphrase(''); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Failed to export configuration'; - notificationStore.error(errorMessage); - logger.error('Export error', error); - } -}; - -const handleImport = async () => { - if (!importPassphrase()) { - notificationStore.error('Please enter the password'); - return; - } - - if (!importFile()) { - notificationStore.error('Please select a file to import'); - return; - } - - // Only check for API token if user is not authenticated via password - // If user is logged in with password, session auth is sufficient - const hasPasswordAuth = securityStatus()?.hasAuthentication; - if (!hasPasswordAuth && securityStatus()?.apiTokenConfigured && !getApiClientToken()) { - setApiTokenModalSource('import'); - setShowApiTokenModal(true); - return; - } - - try { - const fileContent = await importFile()!.text(); - - // Support three formats: - // 1. UI export: {status: "success", data: "base64string"} - // 2. Legacy format: {data: "base64string"} - // 3. CLI export: raw base64 string (no JSON wrapper) - let encryptedData: string; - - // Try to parse as JSON first - try { - const exportData = JSON.parse(fileContent); - - if (typeof exportData === 'string') { - // Raw base64 string wrapped in JSON (edge case) - encryptedData = exportData; - } else if (exportData.data) { - // Standard format with data field - encryptedData = exportData.data; - } else { - notificationStore.error('Invalid backup file format. Expected encrypted data in "data" field.'); + const unsubscribeDiscovery = eventBus.on('discovery_updated', (data) => { + if (!data) { + updateDiscoveredNodesFromServers([]); + setDiscoveryScanStatus((prev) => ({ + ...prev, + scanning: false, + })); return; } - } catch (_parseError) { - // Not JSON - treat entire contents as raw base64 from CLI export - encryptedData = fileContent.trim(); - } - await apiFetchJSON('/api/config/import', { - method: 'POST', - body: JSON.stringify({ - passphrase: importPassphrase(), - data: encryptedData, - }), + if (Array.isArray(data.servers)) { + updateDiscoveredNodesFromServers(data.servers as RawDiscoveredServer[], { + merge: !!data.immediate, + }); + setDiscoveryScanStatus((prev) => ({ + ...prev, + scanning: data.scanning ?? prev.scanning, + lastResultAt: data.timestamp ?? Date.now(), + errors: Array.isArray(data.errors) && data.errors.length > 0 ? data.errors : undefined, + })); + } else if (!data.immediate) { + // Ensure we clear stale results when the update explicitly reports no servers + updateDiscoveredNodesFromServers([]); + setDiscoveryScanStatus((prev) => ({ + ...prev, + scanning: data.scanning ?? prev.scanning, + lastResultAt: data.timestamp ?? prev.lastResultAt, + errors: Array.isArray(data.errors) && data.errors.length > 0 ? data.errors : undefined, + })); + } else { + setDiscoveryScanStatus((prev) => ({ + ...prev, + scanning: data.scanning ?? prev.scanning, + errors: Array.isArray(data.errors) && data.errors.length > 0 ? data.errors : undefined, + })); + } }); - notificationStore.success('Configuration imported successfully. Reloading...'); - setShowImportDialog(false); - setImportPassphrase(''); - setImportFile(null); + const unsubscribeDiscoveryStatus = eventBus.on('discovery_status', (data) => { + if (!data) { + setDiscoveryScanStatus((prev) => ({ + ...prev, + scanning: false, + })); + return; + } - // Reload page to apply new configuration - setTimeout(() => window.location.reload(), 2000); - } catch (error) { - const errorText = error instanceof Error ? error.message : String(error); + setDiscoveryScanStatus((prev) => ({ + ...prev, + scanning: !!data.scanning, + subnet: data.subnet || prev.subnet, + lastScanStartedAt: data.scanning ? (data.timestamp ?? Date.now()) : prev.lastScanStartedAt, + lastResultAt: !data.scanning && data.timestamp ? data.timestamp : prev.lastResultAt, + })); - // Handle specific error cases if possible, though apiFetch usually handles 401/403 - // But for Import, we might want to trigger the token modal if it was a token issue - // Note: apiFetch throws Error with message. + if (typeof data.subnet === 'string' && data.subnet !== discoverySubnet()) { + applySavedDiscoverySubnet(data.subnet); + } + }); - if (errorText.includes('API_TOKEN') || errorText.includes('API_TOKENS')) { + // Poll for node updates when modal is open + let pollInterval: ReturnType | undefined; + createEffect(() => { + // Clear any existing interval first + if (pollInterval) { + clearInterval(pollInterval); + pollInterval = undefined; + } + + if (showNodeModal()) { + // Start polling every 3 seconds when modal is open + pollInterval = setInterval(() => { + loadNodes(); + loadDiscoveredNodes(); + }, 3000); + } + }); + + // Poll for discovered nodes every 30 seconds + const discoveryInterval = setInterval(() => { + loadDiscoveredNodes(); + }, 30000); + + // Clean up on unmount + onCleanup(() => { + unsubscribeAutoRegister(); + unsubscribeRefresh(); + unsubscribeDiscovery(); + unsubscribeDiscoveryStatus(); + if (pollInterval) { + clearInterval(pollInterval); + } + clearInterval(discoveryInterval); + }); + + try { + // Load data with small delays to prevent rate limit bursts + // Load security status first as it's lightweight + await loadSecurityStatus(); + + // Small delay to prevent burst + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Load nodes + await loadNodes(); + + // Another small delay + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Load discovered nodes + await loadDiscoveredNodes(); + + // Load system settings + try { + const systemSettings = await SettingsAPI.getSystemSettings(); + const rawPVESecs = + typeof systemSettings.pvePollingInterval === 'number' + ? Math.round(systemSettings.pvePollingInterval) + : PVE_POLLING_MIN_SECONDS; + const clampedPVESecs = Math.min( + PVE_POLLING_MAX_SECONDS, + Math.max(PVE_POLLING_MIN_SECONDS, rawPVESecs), + ); + setPVEPollingInterval(clampedPVESecs); + const presetMatch = PVE_POLLING_PRESETS.find((opt) => opt.value === clampedPVESecs); + if (presetMatch) { + setPVEPollingSelection(presetMatch.value); + } else { + setPVEPollingSelection('custom'); + setPVEPollingCustomSeconds(clampedPVESecs); + } + setAllowedOrigins(systemSettings.allowedOrigins || '*'); + // Connection timeout is backend-only + // Load discovery settings (default to false when unset) + setDiscoveryEnabled(systemSettings.discoveryEnabled ?? false); + applySavedDiscoverySubnet(systemSettings.discoverySubnet); + // Load embedding settings + setAllowEmbedding(systemSettings.allowEmbedding ?? false); + setAllowedEmbedOrigins(systemSettings.allowedEmbedOrigins || ''); + // Load webhook security settings + setWebhookAllowedPrivateCIDRs(systemSettings.webhookAllowedPrivateCIDRs || ''); + // Load public URL for notifications + setPublicURL(systemSettings.publicURL || ''); + setTemperatureMonitoringEnabled( + typeof systemSettings.temperatureMonitoringEnabled === 'boolean' + ? systemSettings.temperatureMonitoringEnabled + : true, + ); + // Load hideLocalLogin setting + setHideLocalLogin(systemSettings.hideLocalLogin ?? false); + + // Load Docker update actions setting + setDisableDockerUpdateActions(systemSettings.disableDockerUpdateActions ?? false); + + // Backup polling controls + if (typeof systemSettings.backupPollingEnabled === 'boolean') { + setBackupPollingEnabled(systemSettings.backupPollingEnabled); + } else { + setBackupPollingEnabled(true); + } + const intervalSeconds = + typeof systemSettings.backupPollingInterval === 'number' + ? Math.max(0, Math.floor(systemSettings.backupPollingInterval)) + : 0; + setBackupPollingInterval(intervalSeconds); + if (intervalSeconds > 0) { + setBackupPollingCustomMinutes(Math.max(1, Math.round(intervalSeconds / 60))); + } + // Determine if the loaded interval is a custom value + const isPresetInterval = BACKUP_INTERVAL_OPTIONS.some((opt) => opt.value === intervalSeconds); + setBackupPollingUseCustom(!isPresetInterval && intervalSeconds > 0); + // Load auto-update settings + setAutoUpdateEnabled(systemSettings.autoUpdateEnabled || false); + setAutoUpdateCheckInterval(systemSettings.autoUpdateCheckInterval || 24); + setAutoUpdateTime(systemSettings.autoUpdateTime || '03:00'); + if (systemSettings.updateChannel) { + setUpdateChannel(systemSettings.updateChannel as 'stable' | 'rc'); + } + // Track environment variable overrides + if (systemSettings.envOverrides) { + setEnvOverrides(systemSettings.envOverrides); + } + } catch (error) { + logger.error('Failed to load settings', error); + } + + // Load version information + try { + const version = await UpdatesAPI.getVersion(); + setVersionInfo(version); + // Also set it in the store so it's available globally + await updateStore.checkForUpdates(); // This will load version info too + + // Fetch update info and plan from store + const storeInfo = updateStore.updateInfo(); + if (storeInfo) { + setUpdateInfo(storeInfo); + // Fetch update plan if update is available + if (storeInfo.available && storeInfo.latestVersion) { + try { + const plan = await UpdatesAPI.getUpdatePlan(storeInfo.latestVersion); + setUpdatePlan(plan); + } catch (planError) { + logger.warn('Failed to fetch update plan on load', planError); + } + } + } + + // Only use version.channel as fallback if user hasn't configured a preference + // The user's saved updateChannel preference should take priority + // Check the signal value since systemSettings is scoped to the previous try block + if (version.channel && !updateChannel()) { + setUpdateChannel(version.channel as 'stable' | 'rc'); + } + } catch (error) { + logger.error('Failed to load version', error); + } + } catch (error) { + logger.error('Failed to load configuration', error); + } finally { + // Mark initial load as complete even if there were errors + setInitialLoadComplete(true); + } + }); + + // Re-merge temperature data from WebSocket state when it updates + createEffect( + on( + () => state.nodes, + (stateNodes) => { + const currentNodes = nodes(); + + // Only run if we have nodes loaded and state has data + if (stateNodes && stateNodes.length > 0 && currentNodes.length > 0) { + const updatedNodes = currentNodes.map((node) => { + // Match nodes by ID or by name (handling .lan suffix variations) + const stateNode = stateNodes.find((n) => { + // Try exact ID match first + if (n.id === node.id) return true; + // Try exact name match + if (n.name === node.name) return true; + // Try name with/without .lan suffix + const nodeNameBase = node.name.replace(/\.lan$/, ''); + const stateNameBase = n.name.replace(/\.lan$/, ''); + if (nodeNameBase === stateNameBase) return true; + // Also check if state node ID contains the config node name + if (n.id.includes(node.name) || node.name.includes(n.name)) return true; + return false; + }); + + // Merge temperature data from state if available + if (stateNode?.temperature) { + return { ...node, temperature: stateNode.temperature }; + } + return node; + }); + setNodes(updatedNodes); + } + }, + ), + ); + + const saveSettings = async () => { + try { + if ( + activeTab() === 'system-general' || + activeTab() === 'system-network' || + activeTab() === 'system-updates' || + activeTab() === 'system-backups' + ) { + // Save system settings using typed API + await SettingsAPI.updateSystemSettings({ + pvePollingInterval: pvePollingInterval(), + allowedOrigins: allowedOrigins(), + // Connection timeout is backend-only + // Discovery settings are saved immediately on toggle + updateChannel: updateChannel(), + autoUpdateEnabled: autoUpdateEnabled(), + autoUpdateCheckInterval: autoUpdateCheckInterval(), + autoUpdateTime: autoUpdateTime(), + backupPollingEnabled: backupPollingEnabled(), + backupPollingInterval: backupPollingInterval(), + allowEmbedding: allowEmbedding(), + allowedEmbedOrigins: allowedEmbedOrigins(), + webhookAllowedPrivateCIDRs: webhookAllowedPrivateCIDRs(), + publicURL: publicURL(), + }); + } + + notificationStore.success('Settings saved successfully. Service restart may be required for port changes.'); + setHasUnsavedChanges(false); + + // Reload the page after a short delay to ensure the new settings are applied + setTimeout(() => { + window.location.reload(); + }, 3000); + } catch (error) { + notificationStore.error(error instanceof Error ? error.message : 'Failed to save settings'); + } + }; + + const nodePendingDeleteLabel = () => { + const node = nodePendingDelete(); + if (!node) return ''; + return node.displayName || node.name || node.host || node.id; + }; + + const nodePendingDeleteHost = () => nodePendingDelete()?.host || ''; + const nodePendingDeleteType = () => nodePendingDelete()?.type || ''; + const nodePendingDeleteTypeLabel = () => { + switch (nodePendingDeleteType()) { + case 'pve': + return 'Proxmox VE node'; + case 'pbs': + return 'Proxmox Backup Server'; + case 'pmg': + return 'Proxmox Mail Gateway'; + default: + return 'Pulse node'; + } + }; + + const requestDeleteNode = (node: NodeConfigWithStatus) => { + setNodePendingDelete(node); + setShowDeleteNodeModal(true); + }; + + const cancelDeleteNode = () => { + if (deleteNodeLoading()) return; + setShowDeleteNodeModal(false); + setNodePendingDelete(null); + }; + + const deleteNode = async () => { + const pending = nodePendingDelete(); + if (!pending) return; + setDeleteNodeLoading(true); + try { + await NodesAPI.deleteNode(pending.id); + setNodes(nodes().filter((n) => n.id !== pending.id)); + const label = pending.displayName || pending.name || pending.host || pending.id; + notificationStore.success(`${label} removed successfully`); + } catch (error) { + notificationStore.error(error instanceof Error ? error.message : 'Failed to delete node'); + } finally { + setDeleteNodeLoading(false); + setShowDeleteNodeModal(false); + setNodePendingDelete(null); + } + }; + + const testNodeConnection = async (nodeId: string) => { + try { + const node = nodes().find((n) => n.id === nodeId); + if (!node) { + throw new Error('Node not found'); + } + + // Use the existing node test endpoint which uses stored credentials + const result = await NodesAPI.testExistingNode(nodeId); + if (result.status === 'success') { + // Check for warnings in the response + if (result.warnings && Array.isArray(result.warnings) && result.warnings.length > 0) { + const warningMessage = result.message + '\n\nWarnings:\n' + result.warnings.map((w: string) => '• ' + w).join('\n'); + notificationStore.warning(warningMessage); + } else { + notificationStore.success(result.message || 'Connection successful'); + } + } else { + throw new Error(result.message || 'Connection failed'); + } + } catch (error) { + notificationStore.error(error instanceof Error ? error.message : 'Connection test failed'); + } + }; + + const refreshClusterNodes = async (nodeId: string) => { + try { + notificationStore.info('Refreshing cluster membership...', 2000); + const result = await NodesAPI.refreshClusterNodes(nodeId); + if (result.status === 'success') { + if (result.nodesAdded && result.nodesAdded > 0) { + notificationStore.success(`Found ${result.nodesAdded} new node(s) in cluster "${result.clusterName}"`); + } else { + notificationStore.success(`Cluster "${result.clusterName}" membership verified (${result.newNodeCount} nodes)`); + } + // Refresh nodes list to show updated cluster info + await loadNodes(); + } else { + throw new Error('Failed to refresh cluster'); + } + } catch (error) { + notificationStore.error(error instanceof Error ? error.message : 'Failed to refresh cluster membership'); + } + }; + + const checkForUpdates = async () => { + setCheckingForUpdates(true); + try { + // Force check with current channel selection + await updateStore.checkForUpdates(true); + const info = updateStore.updateInfo(); + setUpdateInfo(info); + + // Fetch update plan if update is available + if (info?.available && info.latestVersion) { + try { + const plan = await UpdatesAPI.getUpdatePlan(info.latestVersion); + setUpdatePlan(plan); + } catch (planError) { + logger.warn('Failed to fetch update plan', planError); + setUpdatePlan(null); + } + } else { + setUpdatePlan(null); + } + + // If update was dismissed, clear it so user can see it again + if (info?.available && updateStore.isDismissed()) { + updateStore.clearDismissed(); + } + + if (!info?.available) { + notificationStore.success('You are running the latest version'); + } + } catch (error) { + notificationStore.error('Failed to check for updates'); + logger.error('Update check error', error); + } finally { + setCheckingForUpdates(false); + } + }; + + // Handle install update from settings panel + const handleInstallUpdate = () => { + setShowUpdateConfirmation(true); + }; + + const handleConfirmUpdate = async () => { + const info = updateInfo(); + if (!info?.downloadUrl) return; + + setIsInstallingUpdate(true); + try { + await UpdatesAPI.applyUpdate(info.downloadUrl); + // Close confirmation - GlobalUpdateProgressWatcher will auto-open the progress modal + setShowUpdateConfirmation(false); + } catch (error) { + logger.error('Failed to start update', error); + notificationStore.error('Failed to start update. Please try again.'); + } finally { + setIsInstallingUpdate(false); + } + }; + + const handleExport = async () => { + if (!exportPassphrase()) { + const hasAuth = securityStatus()?.hasAuthentication; + notificationStore.error( + hasAuth + ? useCustomPassphrase() + ? 'Please enter a passphrase' + : 'Please enter your password' + : 'Please enter a passphrase', + ); + return; + } + + // Backend requires at least 12 characters for encryption security + if (exportPassphrase().length < 12) { + const hasAuth = securityStatus()?.hasAuthentication; + notificationStore.error( + hasAuth && !useCustomPassphrase() + ? 'Your password must be at least 12 characters. Please use a custom passphrase instead.' + : 'Passphrase must be at least 12 characters long', + ); + return; + } + + // Only check for API token if user is not authenticated via password + // If user is logged in with password, session auth is sufficient + const hasPasswordAuth = securityStatus()?.hasAuthentication; + if (!hasPasswordAuth && securityStatus()?.apiTokenConfigured && !getApiClientToken()) { + setApiTokenModalSource('export'); + setShowApiTokenModal(true); + return; + } + + try { + // Get CSRF token from cookie + const csrfCookie = document.cookie + .split('; ') + .find((row) => row.startsWith('pulse_csrf=')); + const csrfToken = csrfCookie + ? decodeURIComponent(csrfCookie.split('=').slice(1).join('=')) + : undefined; + + const headers: HeadersInit = { + 'Content-Type': 'application/json', + }; + + // Add CSRF token if available + if (csrfToken) { + headers['X-CSRF-Token'] = csrfToken; + } + + // Add API token if configured + const apiToken = getApiClientToken(); + if (apiToken) { + headers['X-API-Token'] = apiToken; + } + + const data = await apiFetchJSON('/api/config/export', { + method: 'POST', + body: JSON.stringify({ passphrase: exportPassphrase() }), + }); + + // Create and download file + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `pulse-config-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + notificationStore.success('Configuration exported successfully'); + setShowExportDialog(false); + setExportPassphrase(''); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Failed to export configuration'; + notificationStore.error(errorMessage); + logger.error('Export error', error); + } + }; + + const handleImport = async () => { + if (!importPassphrase()) { + notificationStore.error('Please enter the password'); + return; + } + + if (!importFile()) { + notificationStore.error('Please select a file to import'); + return; + } + + // Only check for API token if user is not authenticated via password + // If user is logged in with password, session auth is sufficient + const hasPasswordAuth = securityStatus()?.hasAuthentication; + if (!hasPasswordAuth && securityStatus()?.apiTokenConfigured && !getApiClientToken()) { setApiTokenModalSource('import'); setShowApiTokenModal(true); return; } - notificationStore.error(errorText || 'Failed to import configuration'); - logger.error('Import error', error); - } -}; + try { + const fileContent = await importFile()!.text(); -return ( - <> -
- {/* Page header - no card wrapper for cleaner hierarchy */} -
-

- {headerMeta().title} -

-

{headerMeta().description}

-
+ // Support three formats: + // 1. UI export: {status: "success", data: "base64string"} + // 2. Legacy format: {data: "base64string"} + // 3. CLI export: raw base64 string (no JSON wrapper) + let encryptedData: string; - {/* Save notification bar - only show when there are unsaved changes */} - -
-
-
- - - -
-

Unsaved changes

-

- Your changes will be lost if you navigate away -

-
-
-
- - -
-
-
-
+ } catch (_parseError) { + // Not JSON - treat entire contents as raw base64 from CLI export + encryptedData = fileContent.trim(); + } - -
window.location.reload(), 2000); + } catch (error) { + const errorText = error instanceof Error ? error.message : String(error); + + // Handle specific error cases if possible, though apiFetch usually handles 401/403 + // But for Import, we might want to trigger the token modal if it was a token issue + // Note: apiFetch throws Error with message. + + if (errorText.includes('API_TOKEN') || errorText.includes('API_TOKENS')) { + setApiTokenModalSource('import'); + setShowApiTokenModal(true); + return; + } + + notificationStore.error(errorText || 'Failed to import configuration'); + logger.error('Import error', error); + } + }; + + return ( + <> +
+ {/* Page header - no card wrapper for cleaner hierarchy */} +
+

+ {headerMeta().title} +

+

{headerMeta().description}

+
+ + {/* Save notification bar - only show when there are unsaved changes */} + -
- -
-

Settings

- -
-
- - - -
- - {(group) => ( -
- -

- {group.label} -

-
-
- - {(item) => { - const isActive = () => activeTab() === item.id; - return ( - - ); - }} - -
-
- )} -
-
-
-
- -
- 0}> -
-
- - {(tab) => { - const isActive = activeTab() === tab.id; - const disabled = tab.disabled; - return ( - - ); +
+

Unsaved changes

+

+ Your changes will be lost if you navigate away +

+
+
+
+ +
-
+
+ -
- - - - - {/* Recommendation banner for Proxmox tab */} - -
-
+ +
+
+ +
+

Settings

+ +
+
+ + -
+ + +
+ + {(group) => ( +
+ +

+ {group.label} +

+
+
+ + {(item) => { + const isActive = () => activeTab() === item.id; + return ( + + ); + }} + +
+
+ )} +
+
+
+
+ +
+ 0}> +
+
+ + {(tab) => { + const isActive = activeTab() === tab.id; + const disabled = tab.disabled; + return ( + + ); + }} +
- {/* PVE Nodes Tab */} - -
-
- -
- Loading configuration... -
-
- - -
-
-

- Proxmox VE nodes -

-
- {/* Discovery toggle */} -
- - Discovery - - { - if ( - envOverrides().discoveryEnabled || - savingDiscoverySettings() - ) { - e.preventDefault(); - return; - } - const success = await handleDiscoveryEnabledChange( - e.currentTarget.checked, - ); - if (!success) { - e.currentTarget.checked = discoveryEnabled(); - } - }} - disabled={ - envOverrides().discoveryEnabled || savingDiscoverySettings() - } - containerClass="gap-2" - label={ - - {discoveryEnabled() ? 'On' : 'Off'} - - } - /> -
+
+ + + + + {/* Recommendation banner for Proxmox tab */} + +
+
+ + + +
+

+ Recommended: Install the Pulse agent on your Proxmox nodes for automatic setup, temperature monitoring, and AI features. +

+ +
+
+
+
+ + {/* PVE Nodes Tab */} + +
+
+ +
+ Loading configuration... +
+
+ + +
+
+

+ Proxmox VE nodes +

+
+ {/* Discovery toggle */} +
+ + Discovery + + { + if ( + envOverrides().discoveryEnabled || + savingDiscoverySettings() + ) { + e.preventDefault(); + return; + } + const success = await handleDiscoveryEnabledChange( + e.currentTarget.checked, + ); + if (!success) { + e.currentTarget.checked = discoveryEnabled(); + } + }} + disabled={ + envOverrides().discoveryEnabled || savingDiscoverySettings() + } + containerClass="gap-2" + label={ + + {discoveryEnabled() ? 'On' : 'Off'} + + } + /> +
+ + + + - - +
+
- -
+ onDelete={requestDeleteNode} + onRefreshCluster={refreshClusterNodes} + /> +
+ + n.type === 'pve').length === 0 + } + > +
+
+ +
+

+ No PVE nodes configured +

+

+ Add a Proxmox VE node to start monitoring your infrastructure +

+
+
+ + - 0}> - { - setEditingNode(node); - setCurrentNodeType('pve'); - setShowNodeModal(true); - }} - onDelete={requestDeleteNode} - onRefreshCluster={refreshClusterNodes} - /> - - + {/* Discovered PVE nodes - only show when discovery is enabled */} + +
+
+ + + + Scanning your network for Proxmox VE servers… + + + + + Last scan{' '} + {formatRelativeTime( + discoveryScanStatus().lastResultAt ?? + discoveryScanStatus().lastScanStartedAt, + )} + + +
+
+ Discovery issues: +
    + + {(err) =>
  • {err}
  • } +
    +
+ + /timed out|timeout/i.test(err), + ) + } + > +

+ Large networks can time out in auto mode. Switch to a custom subnet + for faster, targeted scans. +

+
+
+
+ n.type === 'pve').length === 0 } > -
-
- -
-

- No PVE nodes configured -

-

- Add a Proxmox VE node to start monitoring your infrastructure -

+
+ + + Waiting for responses… this can take up to a minute depending on your + network size. +
-
- -
- - {/* Discovered PVE nodes - only show when discovery is enabled */} - -
-
- - - - Scanning your network for Proxmox VE servers… - - - - - Last scan{' '} - {formatRelativeTime( - discoveryScanStatus().lastResultAt ?? - discoveryScanStatus().lastScanStartedAt, - )} - - -
- -
- Discovery issues: -
    - - {(err) =>
  • {err}
  • } -
    -
- - /timed out|timeout/i.test(err), - ) - } - > -

- Large networks can time out in auto mode. Switch to a custom subnet - for faster, targeted scans. -

-
-
-
- n.type === 'pve').length === 0 - } - > -
- - - Waiting for responses… this can take up to a minute depending on your - network size. - -
-
- n.type === 'pve')}> - {(server) => ( -
{ - // Pre-fill the modal with discovered server info - setEditingNode({ - id: '', - type: 'pve', - name: server.hostname || `pve-${server.ip}`, - host: `https://${server.ip}:${server.port}`, - user: '', - tokenName: '', - tokenValue: '', - verifySSL: false, - monitorVMs: true, - monitorContainers: true, - monitorStorage: true, - monitorBackups: true, - monitorPhysicalDisks: false, - status: 'pending', - } as NodeConfigWithStatus); - setCurrentNodeType('pve'); - setShowNodeModal(true); - }} - > -
-
-
-
-
-

- {server.hostname || `Proxmox VE at ${server.ip}`} -

-

- {server.ip}:{server.port} -

-
- - Discovered - - - Click to configure - + n.type === 'pve')}> + {(server) => ( +
{ + // Pre-fill the modal with discovered server info + setEditingNode({ + id: '', + type: 'pve', + name: server.hostname || `pve-${server.ip}`, + host: `https://${server.ip}:${server.port}`, + user: '', + tokenName: '', + tokenValue: '', + verifySSL: false, + monitorVMs: true, + monitorContainers: true, + monitorStorage: true, + monitorBackups: true, + monitorPhysicalDisks: false, + status: 'pending', + } as NodeConfigWithStatus); + setCurrentNodeType('pve'); + setShowNodeModal(true); + }} + > +
+
+
+
+
+

+ {server.hostname || `Proxmox VE at ${server.ip}`} +

+

+ {server.ip}:{server.port} +

+
+ + Discovered + + + Click to configure + +
+ + +
- - -
-
- )} - -
- + )} + +
+ +
-
- + - {/* PBS Nodes Tab */} - -
-
- -
- Loading configuration... -
-
- - -
-
-

- Proxmox Backup Server nodes -

-
- {/* Discovery toggle */} -
- - Discovery - - { - if ( - envOverrides().discoveryEnabled || - savingDiscoverySettings() - ) { - e.preventDefault(); - return; + {/* PBS Nodes Tab */} + +
+
+ +
+ Loading configuration... +
+
+ + +
+
+

+ Proxmox Backup Server nodes +

+
+ {/* Discovery toggle */} +
+ + Discovery + + { + if ( + envOverrides().discoveryEnabled || + savingDiscoverySettings() + ) { + e.preventDefault(); + return; + } + const success = await handleDiscoveryEnabledChange( + e.currentTarget.checked, + ); + if (!success) { + e.currentTarget.checked = discoveryEnabled(); + } + }} + disabled={ + envOverrides().discoveryEnabled || savingDiscoverySettings() } - const success = await handleDiscoveryEnabledChange( - e.currentTarget.checked, - ); - if (!success) { - e.currentTarget.checked = discoveryEnabled(); + containerClass="gap-2" + label={ + + {discoveryEnabled() ? 'On' : 'Off'} + } - }} - disabled={ - envOverrides().discoveryEnabled || savingDiscoverySettings() - } - containerClass="gap-2" - label={ - - {discoveryEnabled() ? 'On' : 'Off'} - - } - /> -
+ /> +
+ + + + - - +
+
- -
+ onDelete={requestDeleteNode} + /> + + + n.type === 'pbs').length === 0 + } + > +
+
+ +
+

+ No PBS nodes configured +

+

+ Add a Proxmox Backup Server to monitor your backups +

+
+
+ +
- 0}> - { - setEditingNode(node); - setCurrentNodeType('pbs'); - setShowNodeModal(true); - }} - onDelete={requestDeleteNode} - /> - - + {/* Discovered PBS nodes - only show when discovery is enabled */} + +
+
+ + + + Scanning your network for Proxmox Backup Servers… + + + + + Last scan{' '} + {formatRelativeTime( + discoveryScanStatus().lastResultAt ?? + discoveryScanStatus().lastScanStartedAt, + )} + + +
+
+ Discovery issues: +
    + + {(err) =>
  • {err}
  • } +
    +
+ + /timed out|timeout/i.test(err), + ) + } + > +

+ Large networks can time out in auto mode. Switch to a custom subnet + for faster, targeted scans. +

+
+
+
+ n.type === 'pbs').length === 0 } > -
-
- -
-

- No PBS nodes configured -

-

- Add a Proxmox Backup Server to monitor your backups -

+
+ + + Waiting for responses… this can take up to a minute depending on your + network size. +
-
- -
- - {/* Discovered PBS nodes - only show when discovery is enabled */} - -
-
- - - - Scanning your network for Proxmox Backup Servers… - - - - - Last scan{' '} - {formatRelativeTime( - discoveryScanStatus().lastResultAt ?? - discoveryScanStatus().lastScanStartedAt, - )} - - -
- -
- Discovery issues: -
    - - {(err) =>
  • {err}
  • } -
    -
- - /timed out|timeout/i.test(err), - ) - } - > -

- Large networks can time out in auto mode. Switch to a custom subnet - for faster, targeted scans. -

-
-
-
- n.type === 'pbs').length === 0 - } - > -
- - - Waiting for responses… this can take up to a minute depending on your - network size. - -
-
- n.type === 'pbs')}> - {(server) => ( -
{ - // Pre-fill the modal with discovered server info - setEditingNode({ - id: '', - type: 'pbs', - name: server.hostname || `pbs-${server.ip}`, - host: `https://${server.ip}:${server.port}`, - user: '', - tokenName: '', - tokenValue: '', - verifySSL: false, - monitorDatastores: true, - monitorSyncJobs: true, - monitorVerifyJobs: true, - monitorPruneJobs: true, - monitorGarbageJobs: true, - status: 'pending', - } as NodeConfigWithStatus); - setCurrentNodeType('pbs'); - setShowNodeModal(true); - }} - > -
-
-
-
-
-

- {server.hostname || `Backup Server at ${server.ip}`} -

-

- {server.ip}:{server.port} -

-
- - Discovered - - - Click to configure - + n.type === 'pbs')}> + {(server) => ( +
{ + // Pre-fill the modal with discovered server info + setEditingNode({ + id: '', + type: 'pbs', + name: server.hostname || `pbs-${server.ip}`, + host: `https://${server.ip}:${server.port}`, + user: '', + tokenName: '', + tokenValue: '', + verifySSL: false, + monitorDatastores: true, + monitorSyncJobs: true, + monitorVerifyJobs: true, + monitorPruneJobs: true, + monitorGarbageJobs: true, + status: 'pending', + } as NodeConfigWithStatus); + setCurrentNodeType('pbs'); + setShowNodeModal(true); + }} + > +
+
+
+
+
+

+ {server.hostname || `Backup Server at ${server.ip}`} +

+

+ {server.ip}:{server.port} +

+
+ + Discovered + + + Click to configure + +
+ + +
- - -
-
- )} - -
- + )} + +
+ +
-
- - {/* PMG Nodes Tab */} - -
-
- -
- Loading configuration... -
-
+ + {/* PMG Nodes Tab */} + +
+
+ +
+ Loading configuration... +
+
- - -
-
-

- Proxmox Mail Gateway nodes -

-
- {/* Discovery toggle */} -
- - Discovery - - { - if ( - envOverrides().discoveryEnabled || - savingDiscoverySettings() - ) { - e.preventDefault(); - return; + + +
+
+

+ Proxmox Mail Gateway nodes +

+
+ {/* Discovery toggle */} +
+ + Discovery + + { + if ( + envOverrides().discoveryEnabled || + savingDiscoverySettings() + ) { + e.preventDefault(); + return; + } + const success = await handleDiscoveryEnabledChange( + e.currentTarget.checked, + ); + if (!success) { + e.currentTarget.checked = discoveryEnabled(); + } + }} + disabled={ + envOverrides().discoveryEnabled || savingDiscoverySettings() } - const success = await handleDiscoveryEnabledChange( - e.currentTarget.checked, - ); - if (!success) { - e.currentTarget.checked = discoveryEnabled(); + containerClass="gap-2" + label={ + + {discoveryEnabled() ? 'On' : 'Off'} + } - }} - disabled={ - envOverrides().discoveryEnabled || savingDiscoverySettings() - } - containerClass="gap-2" - label={ - - {discoveryEnabled() ? 'On' : 'Off'} - - } - /> -
+ /> +
+ + + + - - +
+
-
+ + + + {/* Discovered PMG nodes - only show when discovery is enabled */} + +
+
+ + + + Scanning network... + + + + + Last scan{' '} + {formatRelativeTime( + discoveryScanStatus().lastResultAt ?? + discoveryScanStatus().lastScanStartedAt, + )} + + +
+ +
+ Discovery issues: +
    + + {(err) =>
  • {err}
  • } +
    +
+ + /timed out|timeout/i.test(err), + ) + } + > +

+ Large networks can time out in auto mode. Switch to a custom subnet + for faster, targeted scans. +

+
+
+
+ n.type === 'pmg').length === 0 + } + > +
+ + + + +

Scanning for PMG servers...

+
+
+ n.type === 'pmg')}> + {(server) => ( +
{ setEditingNode(null); setCurrentNodeType('pmg'); setModalResetKey((prev) => prev + 1); setShowNodeModal(true); + setTimeout(() => { + const hostInput = document.querySelector( + 'input[placeholder*="192.168"]', + ) as HTMLInputElement; + if (hostInput) { + hostInput.value = server.ip; + hostInput.dispatchEvent(new Event('input', { bubbles: true })); + } + }, 50); }} - class="px-2 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-1" > - - - - - Add - - -
-
- - 0}> - { - setEditingNode(node); - setCurrentNodeType('pmg'); - setModalResetKey((prev) => prev + 1); - setShowNodeModal(true); - }} - onDelete={requestDeleteNode} - /> - - - -
-
- -
-

- No PMG nodes configured -

-

- Add a Proxmox Mail Gateway node to start monitoring -

-
-
-
- - - - {/* Discovered PMG nodes - only show when discovery is enabled */} - -
-
- - - - Scanning network... - - - - - Last scan{' '} - {formatRelativeTime( - discoveryScanStatus().lastResultAt ?? - discoveryScanStatus().lastScanStartedAt, - )} - - -
- -
- Discovery issues: -
    - - {(err) =>
  • {err}
  • } -
    -
- - /timed out|timeout/i.test(err), - ) - } - > -

- Large networks can time out in auto mode. Switch to a custom subnet - for faster, targeted scans. -

-
-
-
- n.type === 'pmg').length === 0 - } - > -
- - - - -

Scanning for PMG servers...

-
-
- n.type === 'pmg')}> - {(server) => ( -
{ - setEditingNode(null); - setCurrentNodeType('pmg'); - setModalResetKey((prev) => prev + 1); - setShowNodeModal(true); - setTimeout(() => { - const hostInput = document.querySelector( - 'input[placeholder*="192.168"]', - ) as HTMLInputElement; - if (hostInput) { - hostInput.value = server.ip; - hostInput.dispatchEvent(new Event('input', { bubbles: true })); - } - }, 50); - }} - > -
-
- - - - -
-

- {server.hostname || `PMG at ${server.ip}`} -

-

- {server.ip}:{server.port} -

-
- - Discovered - - - Click to configure - +
+
+ + + + +
+

+ {server.hostname || `PMG at ${server.ip}`} +

+

+ {server.ip}:{server.port} +

+
+ + Discovered + + + Click to configure + +
+ + +
- - -
-
- )} - -
- -
-
- - {/* Unified Agents Tab */} - - {/* Docker Settings Card */} - -
-
-

Docker Settings

-

- Server-wide settings for Docker container management. -

+ )} + +
+
+
+
+ {/* Unified Agents Tab */} + + {/* Docker Settings Card */} + +
+
+

Docker Settings

+

+ Server-wide settings for Docker container management. +

+
- {/* Hide Docker Update Buttons Toggle */} -
-
-
- - Hide Docker Update Buttons - - - - - - - ENV + {/* Hide Docker Update Buttons Toggle */} +
+
+
+ + Hide Docker Update Buttons - + + + + + + ENV + + +
+

+ When enabled, the "Update" button on Docker containers will be hidden across all views. + Update detection will still work, allowing you to see which containers have updates available. + Use this in production environments where you prefer Pulse to be read-only. +

+

+ Can also be set via environment variable: PULSE_DISABLE_DOCKER_UPDATE_ACTIONS=true +

+
+
+
-

- When enabled, the "Update" button on Docker containers will be hidden across all views. - Update detection will still work, allowing you to see which containers have updates available. - Use this in production environments where you prefer Pulse to be read-only. -

-

- Can also be set via environment variable: PULSE_DISABLE_DOCKER_UPDATE_ACTIONS=true -

-
-
-
+ + + + + {/* Agent Profiles (Pro Feature) */} + + + + {/* System General Tab */} + + + + + {/* System Network Tab */} + + { + discoverySubnetInputRef = el; + }} + /> + + + {/* System Updates Tab */} + + + + + {/* System Backups Tab */} + + + + + {/* AI Assistant Tab */} + +
+ +
- +
- + {/* Pulse Pro License Tab */} + + + - {/* Agent Profiles (Pro Feature) */} - - + {/* API Access */} + + { + void loadSecurityStatus(); + }} + refreshing={securityStatusLoading()} + /> + - {/* System General Tab */} - - - + {/* Security Overview Tab */} + + + - {/* System Network Tab */} - - { - discoverySubnetInputRef = el; - }} - /> - + {/* Security Authentication Tab */} + + + - {/* System Updates Tab */} - - - + {/* Security Single Sign-On Tab */} + +
+ +
+
- {/* System Backups Tab */} - - - + {/* Security Audit Log Tab */} + + + - {/* AI Assistant Tab */} - -
- - -
-
- - {/* Pulse Pro License Tab */} - - - - - {/* API Access */} - - { - void loadSecurityStatus(); - }} - refreshing={securityStatusLoading()} - /> - - - {/* Security Overview Tab */} - - - - - {/* Security Authentication Tab */} - - - - - {/* Security Single Sign-On Tab */} - -
- -
-
- - {/* Security Audit Log Tab */} - - - - - {/* Diagnostics Tab */} - - - -
-
- -
- - {/* Delete Node Modal */} - < Show when={showDeleteNodeModal()} > -
- - -
-

- Removing this {nodePendingDeleteTypeLabel().toLowerCase()} also scrubs the Pulse - footprint on the host — the proxy service, SSH key, API token, and bind mount are - all cleaned up automatically. -

-
-

What happens next

-
    -
  • Pulse removes the node entry and clears related alerts.
  • -
  • - {nodePendingDeleteHost() ? ( - <> - The host {nodePendingDeleteHost()} loses - the proxy service, SSH key, and API token. - - ) : ( - 'The host loses the proxy service, SSH key, and API token.' - )} -
  • -
  • - If the host comes back later, rerunning the setup script reinstalls everything - with a fresh key. -
  • - -
  • - Backup user tokens on the PBS are removed, so jobs referencing them will no - longer authenticate until the node is re-added. -
  • -
    - -
  • - Mail gateway tokens are removed as part of the cleanup; re-enroll to restore - outbound telemetry. -
  • -
    -
+ {/* Diagnostics Tab */} + + +
-
+
+
+
-
- - -
- -
-
- - {/* Node Modal - Use separate modals for PVE and PBS to ensure clean state */} - < Show when={isNodeModalVisible('pve')} > - { - setShowNodeModal(false); - setEditingNode(null); - // Increment resetKey to force form reset on next open - setModalResetKey((prev) => prev + 1); - }} - nodeType="pve" - editingNode={editingNode()?.type === 'pve' ? (editingNode() ?? undefined) : undefined} - securityStatus={securityStatus() ?? undefined} - temperatureMonitoringEnabled={resolveTemperatureMonitoringEnabled( - editingNode()?.type === 'pve' ? editingNode() : null, - )} - temperatureMonitoringLocked={temperatureMonitoringLocked()} - savingTemperatureSetting={savingTemperatureSetting()} - onToggleTemperatureMonitoring={ - editingNode()?.id - ? (enabled: boolean) => handleNodeTemperatureMonitoringChange(editingNode()!.id, enabled) - : handleTemperatureMonitoringChange - } - onSave={async (nodeData) => { - try { - if (editingNode() && editingNode()!.id) { - // Update existing node (only if it has a valid ID) - await NodesAPI.updateNode(editingNode()!.id, nodeData as NodeConfig); - - // Update local state - setNodes( - nodes().map((n) => - n.id === editingNode()!.id - ? { - ...n, - ...nodeData, - // Update hasPassword/hasToken based on whether credentials were provided - hasPassword: nodeData.password ? true : n.hasPassword, - hasToken: nodeData.tokenValue ? true : n.hasToken, - status: 'pending', - } - : n, - ), - ); - notificationStore.success('Node updated successfully'); - } else { - // Add new node - await NodesAPI.addNode(nodeData as NodeConfig); - - // Reload nodes to get the new ID - const nodesList = await NodesAPI.getNodes(); - const nodesWithStatus = nodesList.map((node) => ({ - ...node, - // Use the hasPassword/hasToken from the API if available, otherwise check local fields - hasPassword: node.hasPassword ?? !!node.password, - hasToken: node.hasToken ?? !!node.tokenValue, - status: node.status || ('pending' as const), - })); - setNodes(nodesWithStatus); - notificationStore.success('Node added successfully'); - } - - setShowNodeModal(false); - setEditingNode(null); - } catch (error) { - notificationStore.error(error instanceof Error ? error.message : 'Operation failed'); - } - }} - /> - - - {/* PBS Node Modal - Separate instance to prevent contamination */} - < Show when={isNodeModalVisible('pbs')} > - { - setShowNodeModal(false); - setEditingNode(null); - // Increment resetKey to force form reset on next open - setModalResetKey((prev) => prev + 1); - }} - nodeType="pbs" - editingNode={editingNode()?.type === 'pbs' ? (editingNode() ?? undefined) : undefined} - securityStatus={securityStatus() ?? undefined} - temperatureMonitoringEnabled={resolveTemperatureMonitoringEnabled( - editingNode()?.type === 'pbs' ? editingNode() : null, - )} - temperatureMonitoringLocked={temperatureMonitoringLocked()} - savingTemperatureSetting={savingTemperatureSetting()} - onToggleTemperatureMonitoring={ - editingNode()?.id - ? (enabled: boolean) => handleNodeTemperatureMonitoringChange(editingNode()!.id, enabled) - : handleTemperatureMonitoringChange - } - onSave={async (nodeData) => { - try { - if (editingNode() && editingNode()!.id) { - // Update existing node (only if it has a valid ID) - await NodesAPI.updateNode(editingNode()!.id, nodeData as NodeConfig); - - // Update local state - setNodes( - nodes().map((n) => - n.id === editingNode()!.id - ? { - ...n, - ...nodeData, - hasPassword: nodeData.password ? true : n.hasPassword, - hasToken: nodeData.tokenValue ? true : n.hasToken, - status: 'pending', - } - : n, - ), - ); - notificationStore.success('Node updated successfully'); - } else { - // Add new node - await NodesAPI.addNode(nodeData as NodeConfig); - - // Reload the nodes list to get the latest state - const nodesList = await NodesAPI.getNodes(); - const nodesWithStatus = nodesList.map((node) => ({ - ...node, - // Use the hasPassword/hasToken from the API if available, otherwise check local fields - hasPassword: node.hasPassword ?? !!node.password, - hasToken: node.hasToken ?? !!node.tokenValue, - status: node.status || ('pending' as const), - })); - setNodes(nodesWithStatus); - notificationStore.success('Node added successfully'); - } - - setShowNodeModal(false); - setEditingNode(null); - } catch (error) { - notificationStore.error(error instanceof Error ? error.message : 'Operation failed'); - } - }} - /> - - - {/* PMG Node Modal */} - < Show when={isNodeModalVisible('pmg')} > - { - setShowNodeModal(false); - setEditingNode(null); - setModalResetKey((prev) => prev + 1); - }} - nodeType="pmg" - editingNode={editingNode()?.type === 'pmg' ? (editingNode() ?? undefined) : undefined} - securityStatus={securityStatus() ?? undefined} - temperatureMonitoringEnabled={resolveTemperatureMonitoringEnabled( - editingNode()?.type === 'pmg' ? editingNode() : null, - )} - temperatureMonitoringLocked={temperatureMonitoringLocked()} - savingTemperatureSetting={savingTemperatureSetting()} - onToggleTemperatureMonitoring={ - editingNode()?.id - ? (enabled: boolean) => handleNodeTemperatureMonitoringChange(editingNode()!.id, enabled) - : handleTemperatureMonitoringChange - } - onSave={async (nodeData) => { - try { - if (editingNode() && editingNode()!.id) { - await NodesAPI.updateNode(editingNode()!.id, nodeData as NodeConfig); - setNodes( - nodes().map((n) => - n.id === editingNode()!.id - ? { - ...n, - ...nodeData, - hasPassword: nodeData.password ? true : n.hasPassword, - hasToken: nodeData.tokenValue ? true : n.hasToken, - status: 'pending', - } - : n, - ), - ); - notificationStore.success('Node updated successfully'); - } else { - await NodesAPI.addNode(nodeData as NodeConfig); - const nodesList = await NodesAPI.getNodes(); - const nodesWithStatus = nodesList.map((node) => ({ - ...node, - hasPassword: node.hasPassword ?? !!node.password, - hasToken: node.hasToken ?? !!node.tokenValue, - status: node.status || ('pending' as const), - })); - setNodes(nodesWithStatus); - notificationStore.success('Node added successfully'); - } - - setShowNodeModal(false); - setEditingNode(null); - } catch (error) { - notificationStore.error(error instanceof Error ? error.message : 'Operation failed'); - } - }} - /> - - - {/* Update Confirmation Modal */} - < UpdateConfirmationModal - isOpen={showUpdateConfirmation()} - onClose={() => setShowUpdateConfirmation(false)} - onConfirm={handleConfirmUpdate} - currentVersion={versionInfo()?.version || 'Unknown'} - latestVersion={updateInfo()?.latestVersion || ''} - plan={updatePlan() || { - canAutoUpdate: false, - requiresRoot: false, - rollbackSupport: false, - }} - isApplying={isInstallingUpdate()} - /> - - {/* Export Dialog */} - < Show when={showExportDialog()} > -
- - - -
- {/* Password Choice Section - Only show if auth is enabled */} - -
-
- - - -
+ {/* Delete Node Modal */} + < Show when={showDeleteNodeModal()} > +
+ + +
+

+ Removing this {nodePendingDeleteTypeLabel().toLowerCase()} also scrubs the Pulse + footprint on the host — the proxy service, SSH key, API token, and bind mount are + all cleaned up automatically. +

+
+

What happens next

+
    +
  • Pulse removes the node entry and clears related alerts.
  • +
  • + {nodePendingDeleteHost() ? ( + <> + The host {nodePendingDeleteHost()} loses + the proxy service, SSH key, and API token. + + ) : ( + 'The host loses the proxy service, SSH key, and API token.' + )} +
  • +
  • + If the host comes back later, rerunning the setup script reinstalls everything + with a fresh key. +
  • + +
  • + Backup user tokens on the PBS are removed, so jobs referencing them will no + longer authenticate until the node is re-added. +
  • +
    + +
  • + Mail gateway tokens are removed as part of the cleanup; re-enroll to restore + outbound telemetry. +
  • +
    +
- +
- {/* Show password input based on selection */} -
- - setExportPassphrase(e.currentTarget.value)} - placeholder={ - securityStatus()?.hasAuthentication +
+ + +
+ +
+ + + {/* Node Modal - Use separate modals for PVE and PBS to ensure clean state */} + < Show when={isNodeModalVisible('pve')} > + { + setShowNodeModal(false); + setEditingNode(null); + // Increment resetKey to force form reset on next open + setModalResetKey((prev) => prev + 1); + }} + nodeType="pve" + editingNode={editingNode()?.type === 'pve' ? (editingNode() ?? undefined) : undefined} + securityStatus={securityStatus() ?? undefined} + temperatureMonitoringEnabled={resolveTemperatureMonitoringEnabled( + editingNode()?.type === 'pve' ? editingNode() : null, + )} + temperatureMonitoringLocked={temperatureMonitoringLocked()} + savingTemperatureSetting={savingTemperatureSetting()} + onToggleTemperatureMonitoring={ + editingNode()?.id + ? (enabled: boolean) => handleNodeTemperatureMonitoringChange(editingNode()!.id, enabled) + : handleTemperatureMonitoringChange + } + onSave={async (nodeData) => { + try { + if (editingNode() && editingNode()!.id) { + // Update existing node (only if it has a valid ID) + await NodesAPI.updateNode(editingNode()!.id, nodeData as NodeConfig); + + // Update local state + setNodes( + nodes().map((n) => + n.id === editingNode()!.id + ? { + ...n, + ...nodeData, + // Update hasPassword/hasToken based on whether credentials were provided + hasPassword: nodeData.password ? true : n.hasPassword, + hasToken: nodeData.tokenValue ? true : n.hasToken, + status: 'pending', + } + : n, + ), + ); + notificationStore.success('Node updated successfully'); + } else { + // Add new node + await NodesAPI.addNode(nodeData as NodeConfig); + + // Reload nodes to get the new ID + const nodesList = await NodesAPI.getNodes(); + const nodesWithStatus = nodesList.map((node) => ({ + ...node, + // Use the hasPassword/hasToken from the API if available, otherwise check local fields + hasPassword: node.hasPassword ?? !!node.password, + hasToken: node.hasToken ?? !!node.tokenValue, + status: node.status || ('pending' as const), + })); + setNodes(nodesWithStatus); + notificationStore.success('Node added successfully'); + } + + setShowNodeModal(false); + setEditingNode(null); + } catch (error) { + notificationStore.error(error instanceof Error ? error.message : 'Operation failed'); + } + }} + /> + + + {/* PBS Node Modal - Separate instance to prevent contamination */} + < Show when={isNodeModalVisible('pbs')} > + { + setShowNodeModal(false); + setEditingNode(null); + // Increment resetKey to force form reset on next open + setModalResetKey((prev) => prev + 1); + }} + nodeType="pbs" + editingNode={editingNode()?.type === 'pbs' ? (editingNode() ?? undefined) : undefined} + securityStatus={securityStatus() ?? undefined} + temperatureMonitoringEnabled={resolveTemperatureMonitoringEnabled( + editingNode()?.type === 'pbs' ? editingNode() : null, + )} + temperatureMonitoringLocked={temperatureMonitoringLocked()} + savingTemperatureSetting={savingTemperatureSetting()} + onToggleTemperatureMonitoring={ + editingNode()?.id + ? (enabled: boolean) => handleNodeTemperatureMonitoringChange(editingNode()!.id, enabled) + : handleTemperatureMonitoringChange + } + onSave={async (nodeData) => { + try { + if (editingNode() && editingNode()!.id) { + // Update existing node (only if it has a valid ID) + await NodesAPI.updateNode(editingNode()!.id, nodeData as NodeConfig); + + // Update local state + setNodes( + nodes().map((n) => + n.id === editingNode()!.id + ? { + ...n, + ...nodeData, + hasPassword: nodeData.password ? true : n.hasPassword, + hasToken: nodeData.tokenValue ? true : n.hasToken, + status: 'pending', + } + : n, + ), + ); + notificationStore.success('Node updated successfully'); + } else { + // Add new node + await NodesAPI.addNode(nodeData as NodeConfig); + + // Reload the nodes list to get the latest state + const nodesList = await NodesAPI.getNodes(); + const nodesWithStatus = nodesList.map((node) => ({ + ...node, + // Use the hasPassword/hasToken from the API if available, otherwise check local fields + hasPassword: node.hasPassword ?? !!node.password, + hasToken: node.hasToken ?? !!node.tokenValue, + status: node.status || ('pending' as const), + })); + setNodes(nodesWithStatus); + notificationStore.success('Node added successfully'); + } + + setShowNodeModal(false); + setEditingNode(null); + } catch (error) { + notificationStore.error(error instanceof Error ? error.message : 'Operation failed'); + } + }} + /> + + + {/* PMG Node Modal */} + < Show when={isNodeModalVisible('pmg')} > + { + setShowNodeModal(false); + setEditingNode(null); + setModalResetKey((prev) => prev + 1); + }} + nodeType="pmg" + editingNode={editingNode()?.type === 'pmg' ? (editingNode() ?? undefined) : undefined} + securityStatus={securityStatus() ?? undefined} + temperatureMonitoringEnabled={resolveTemperatureMonitoringEnabled( + editingNode()?.type === 'pmg' ? editingNode() : null, + )} + temperatureMonitoringLocked={temperatureMonitoringLocked()} + savingTemperatureSetting={savingTemperatureSetting()} + onToggleTemperatureMonitoring={ + editingNode()?.id + ? (enabled: boolean) => handleNodeTemperatureMonitoringChange(editingNode()!.id, enabled) + : handleTemperatureMonitoringChange + } + onSave={async (nodeData) => { + try { + if (editingNode() && editingNode()!.id) { + await NodesAPI.updateNode(editingNode()!.id, nodeData as NodeConfig); + setNodes( + nodes().map((n) => + n.id === editingNode()!.id + ? { + ...n, + ...nodeData, + hasPassword: nodeData.password ? true : n.hasPassword, + hasToken: nodeData.tokenValue ? true : n.hasToken, + status: 'pending', + } + : n, + ), + ); + notificationStore.success('Node updated successfully'); + } else { + await NodesAPI.addNode(nodeData as NodeConfig); + const nodesList = await NodesAPI.getNodes(); + const nodesWithStatus = nodesList.map((node) => ({ + ...node, + hasPassword: node.hasPassword ?? !!node.password, + hasToken: node.hasToken ?? !!node.tokenValue, + status: node.status || ('pending' as const), + })); + setNodes(nodesWithStatus); + notificationStore.success('Node added successfully'); + } + + setShowNodeModal(false); + setEditingNode(null); + } catch (error) { + notificationStore.error(error instanceof Error ? error.message : 'Operation failed'); + } + }} + /> + + + {/* Update Confirmation Modal */} + < UpdateConfirmationModal + isOpen={showUpdateConfirmation()} + onClose={() => setShowUpdateConfirmation(false)} + onConfirm={handleConfirmUpdate} + currentVersion={versionInfo()?.version || 'Unknown'} + latestVersion={updateInfo()?.latestVersion || ''} + plan={updatePlan() || { + canAutoUpdate: false, + requiresRoot: false, + rollbackSupport: false, + }} + isApplying={isInstallingUpdate()} + /> + + {/* Export Dialog */} + < Show when={showExportDialog()} > +
+ + + +
+ {/* Password Choice Section - Only show if auth is enabled */} + +
+
+ + + +
+
+
+ + {/* Show password input based on selection */} +
+
+ ? 'Custom Passphrase' + : 'Enter Your Login Password' + : 'Encryption Passphrase'} + + setExportPassphrase(e.currentTarget.value)} + placeholder={ + securityStatus()?.hasAuthentication + ? useCustomPassphrase() + ? 'Enter a strong passphrase' + : 'Enter your Pulse login password' + : 'Enter a strong passphrase for encryption' + } + class={controlClass()} + /> + +

+ You'll need this passphrase to restore the backup. +

+
+ +

+ You'll use this same password when restoring the backup +

+
+
-
-
- - - -
- Important: The backup contains node credentials but NOT - authentication settings. Each Pulse instance should configure its own login - credentials for security. Remember your{' '} - {useCustomPassphrase() || !securityStatus()?.hasAuthentication - ? 'passphrase' - : 'password'}{' '} - for restoring. +
+
+ + + +
+ Important: The backup contains node credentials but NOT + authentication settings. Each Pulse instance should configure its own login + credentials for security. Remember your{' '} + {useCustomPassphrase() || !securityStatus()?.hasAuthentication + ? 'passphrase' + : 'password'}{' '} + for restoring. +
+ +
+ + +
+
+ +
+ + + {/* API Token Modal */} + < Show when={showApiTokenModal()} > +
+ + + +
+

+ This Pulse instance requires an API token for export/import operations. Please enter + the API token configured on the server. +

+ +
+ + setApiTokenInput(e.currentTarget.value)} + placeholder="Enter API token" + class={controlClass()} + /> +
+ +
+

The API token is set as an environment variable:

+ API_TOKENS=token-for-export,token-for-automation +
-
+
- -
-
-
-
- - - {/* API Token Modal */} - < Show when={showApiTokenModal()} > -
- - - -
-

- This Pulse instance requires an API token for export/import operations. Please enter - the API token configured on the server. -

- -
- - setApiTokenInput(e.currentTarget.value)} - placeholder="Enter API token" - class={controlClass()} - /> -
- -
-

The API token is set as an environment variable:

- API_TOKENS=token-for-export,token-for-automation -
-
- -
- - -
-
-
- - - {/* Import Dialog */} - < Show when={showImportDialog()} > -
- - - -
-
- - { - const file = e.currentTarget.files?.[0]; - if (file) setImportFile(file); - }} - class={controlClass('cursor-pointer')} - /> -
- -
- - setImportPassphrase(e.currentTarget.value)} - placeholder="Enter the password used when creating this backup" - class={controlClass()} - /> -

- This is usually your Pulse login password, unless you used a custom passphrase -

-
- -
-

- Warning: Importing will replace all current configuration. This - action cannot be undone. -

-
- -
-
-
-
-
- + +
+ - { - setShowPasswordModal(false); - // Refresh security status after password change - loadSecurityStatus(); - }} - /> - -); + {/* Import Dialog */} + < Show when={showImportDialog()} > +
+ + + +
+
+ + { + const file = e.currentTarget.files?.[0]; + if (file) setImportFile(file); + }} + class={controlClass('cursor-pointer')} + /> +
+ +
+ + setImportPassphrase(e.currentTarget.value)} + placeholder="Enter the password used when creating this backup" + class={controlClass()} + /> +

+ This is usually your Pulse login password, unless you used a custom passphrase +

+
+ +
+

+ Warning: Importing will replace all current configuration. This + action cannot be undone. +

+
+ +
+ + +
+
+
+
+ + + { + setShowPasswordModal(false); + // Refresh security status after password change + loadSecurityStatus(); + }} + /> + + ); }; export default Settings; diff --git a/frontend-modern/src/stores/license.ts b/frontend-modern/src/stores/license.ts new file mode 100644 index 000000000..9f2444421 --- /dev/null +++ b/frontend-modern/src/stores/license.ts @@ -0,0 +1,66 @@ +import { createSignal, createMemo } from 'solid-js'; +import { LicenseAPI, type LicenseStatus } from '@/api/license'; +import { logger } from '@/utils/logger'; + +// Reactive signals for license status +const [licenseStatus, setLicenseStatus] = createSignal(null); +const [loading, setLoading] = createSignal(false); +const [loaded, setLoaded] = createSignal(false); + +/** + * Load the license status from the server. + */ +export async function loadLicenseStatus(force = false): Promise { + if (loaded() && !force) return; + + setLoading(true); + try { + const status = await LicenseAPI.getStatus(); + setLicenseStatus(status); + setLoaded(true); + logger.debug('[licenseStore] License status loaded', { tier: status.tier, valid: status.valid }); + } catch (err) { + logger.error('[licenseStore] Failed to load license status', err); + // Fallback to free tier on error to avoid breaking UI + setLicenseStatus({ + valid: false, + tier: 'free', + is_lifetime: false, + days_remaining: 0, + features: [], + }); + setLoaded(true); + } finally { + setLoading(false); + } +} + +/** + * Helper to check if the current license is Pulse Pro or Enterprise. + */ +export const isPro = createMemo(() => { + const current = licenseStatus(); + return Boolean(current?.valid && current.tier !== 'free'); +}); + +/** + * Helper to check if the current license is Enterprise. + */ +export const isEnterprise = createMemo(() => { + const current = licenseStatus(); + return Boolean(current?.valid && (current.tier === 'enterprise' || current.tier === 'msp')); +}); + +/** + * Check if a specific feature is enabled by the current license. + */ +export function hasFeature(feature: string): boolean { + const current = licenseStatus(); + if (!current?.valid) return false; + return current.features.includes(feature); +} + +/** + * Get the full license status. + */ +export { licenseStatus, loading as licenseLoading, loaded as licenseLoaded }; diff --git a/internal/api/temperature_proxy.go b/internal/api/temperature_proxy.go index 135b686ce..a8ef259b2 100644 --- a/internal/api/temperature_proxy.go +++ b/internal/api/temperature_proxy.go @@ -150,7 +150,8 @@ func buildAuthorizedNodeList(instances []config.PVEInstance) []authorizedNode { if instance.ClusterEndpoints != nil { for _, ep := range instance.ClusterEndpoints { name := ep.NodeName - ip := ep.IP + // Use EffectiveIP() which prefers IPOverride over auto-discovered IP + ip := ep.EffectiveIP() if ip == "" { ip = extractHostPart(ep.Host) } diff --git a/internal/config/config.go b/internal/config/config.go index bbe5e101c..ad36ae9c6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -458,7 +458,8 @@ type ClusterEndpoint struct { NodeName string // Node name Host string // Full URL (e.g., https://node1.lan:8006) GuestURL string // Optional guest-accessible URL (for navigation) - IP string // IP address + IP string // IP address (auto-discovered from cluster) + IPOverride string // User-specified IP override (takes precedence over IP if set) Fingerprint string // TLS certificate fingerprint (SHA256, auto-captured via TOFU) Online bool // Current online status from Proxmox LastSeen time.Time // Last successful connection @@ -470,6 +471,14 @@ type ClusterEndpoint struct { TemperatureProxyControlToken string // Control-plane token for this specific node } +// EffectiveIP returns the IP to use for this endpoint, preferring IPOverride if set +func (e ClusterEndpoint) EffectiveIP() string { + if e.IPOverride != "" { + return e.IPOverride + } + return e.IP +} + // PBSInstance represents a Proxmox Backup Server connection type PBSInstance struct { Name string diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index bab02bbc0..8c4d2e581 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -537,18 +537,21 @@ func clusterEndpointEffectiveURL(endpoint config.ClusterEndpoint, verifySSL bool // bypasses hostname checks), prefer IP to reduce DNS lookups (refs #620). requiresHostnameForTLS := verifySSL && !hasFingerprint + // Use EffectiveIP() which prefers user-specified IPOverride over auto-discovered IP + effectiveIP := endpoint.EffectiveIP() + if requiresHostnameForTLS { // Prefer hostname for proper TLS certificate validation if endpoint.Host != "" { return ensureClusterEndpointURL(endpoint.Host) } - if endpoint.IP != "" { - return ensureClusterEndpointURL(endpoint.IP) + if effectiveIP != "" { + return ensureClusterEndpointURL(effectiveIP) } } else { // Prefer IP address to avoid excessive DNS lookups - if endpoint.IP != "" { - return ensureClusterEndpointURL(endpoint.IP) + if effectiveIP != "" { + return ensureClusterEndpointURL(effectiveIP) } if endpoint.Host != "" { return ensureClusterEndpointURL(endpoint.Host) @@ -3879,10 +3882,13 @@ func (m *Monitor) getConfiguredHostIPs() []string { // Add PVE hosts for _, pve := range m.config.PVEInstances { addHost(pve.Host) - // Also add cluster endpoints + // Also add cluster endpoints (include both auto-discovered IP and override if set) for _, ep := range pve.ClusterEndpoints { addHost(ep.Host) addHost(ep.IP) + if ep.IPOverride != "" && ep.IPOverride != ep.IP { + addHost(ep.IPOverride) + } } } @@ -4265,7 +4271,8 @@ func (m *Monitor) retryFailedConnections(ctx context.Context) { endpointFingerprints := make(map[string]string) for _, ep := range pve.ClusterEndpoints { - host := ep.IP + // Use EffectiveIP() which prefers IPOverride over auto-discovered IP + host := ep.EffectiveIP() if host == "" { host = ep.Host } diff --git a/scripts/build-release.sh b/scripts/build-release.sh index bbdf82682..cb470c1ab 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -344,11 +344,21 @@ tar -czf "$RELEASE_DIR/pulse-agent-v${VERSION}-darwin-arm64.tar.gz" -C "$BUILD_D # FreeBSD tar -czf "$RELEASE_DIR/pulse-agent-v${VERSION}-freebsd-amd64.tar.gz" -C "$BUILD_DIR" pulse-agent-freebsd-amd64 tar -czf "$RELEASE_DIR/pulse-agent-v${VERSION}-freebsd-arm64.tar.gz" -C "$BUILD_DIR" pulse-agent-freebsd-arm64 -# Windows +# Windows (zip archives with version in filename) zip -j "$RELEASE_DIR/pulse-agent-v${VERSION}-windows-amd64.zip" "$BUILD_DIR/pulse-agent-windows-amd64.exe" zip -j "$RELEASE_DIR/pulse-agent-v${VERSION}-windows-arm64.zip" "$BUILD_DIR/pulse-agent-windows-arm64.exe" zip -j "$RELEASE_DIR/pulse-agent-v${VERSION}-windows-386.zip" "$BUILD_DIR/pulse-agent-windows-386.exe" +# Also copy bare Windows EXEs for /releases/latest/download/ redirect compatibility +# These allow LXC/barebone installs to redirect to GitHub without needing versioned URLs +echo "Copying bare Windows EXEs to release directory for redirect compatibility..." +cp "$BUILD_DIR/pulse-agent-windows-amd64.exe" "$RELEASE_DIR/" +cp "$BUILD_DIR/pulse-agent-windows-arm64.exe" "$RELEASE_DIR/" +cp "$BUILD_DIR/pulse-agent-windows-386.exe" "$RELEASE_DIR/" +cp "$BUILD_DIR/pulse-host-agent-windows-amd64.exe" "$RELEASE_DIR/" +cp "$BUILD_DIR/pulse-host-agent-windows-arm64.exe" "$RELEASE_DIR/" +cp "$BUILD_DIR/pulse-host-agent-windows-386.exe" "$RELEASE_DIR/" + # Copy Windows, macOS, and FreeBSD binaries into universal tarball for /download/ endpoint echo "Adding Windows, macOS, and FreeBSD binaries to universal tarball..." cp "$BUILD_DIR/pulse-host-agent-darwin-amd64" "$universal_dir/bin/" @@ -410,8 +420,8 @@ cp scripts/pulse-auto-update.sh "$RELEASE_DIR/" # Generate checksums (include tarballs, zip files, helm chart, and install.sh) cd "$RELEASE_DIR" shopt -s nullglob extglob -# Match all tarballs, zip files, and install scripts -checksum_files=( *.tar.gz *.zip install.sh install-sensor-proxy.sh install-docker.sh pulse-auto-update.sh ) +# Match all tarballs, zip files, exe files, and install scripts +checksum_files=( *.tar.gz *.zip *.exe install.sh install-sensor-proxy.sh install-docker.sh pulse-auto-update.sh ) if compgen -G "pulse-*.tgz" > /dev/null; then checksum_files+=( pulse-*.tgz ) fi