import { Component, createSignal, onMount, For, Show, createEffect, onCleanup, on } from 'solid-js'; import type { JSX } from 'solid-js'; import { useNavigate, useLocation } from '@solidjs/router'; import { useWebSocket } from '@/App'; import { showSuccess, showError } from '@/utils/toast'; import { copyToClipboard } from '@/utils/clipboard'; import { NodeModal } from './NodeModal'; import { APITokenManager } from './APITokenManager'; import { ChangePasswordModal } from './ChangePasswordModal'; import { GuestURLs } from './GuestURLs'; import { DockerAgents } from './DockerAgents'; import { OIDCPanel } from './OIDCPanel'; import { QuickSecuritySetup } from './QuickSecuritySetup'; import { SecurityPostureSummary } from './SecurityPostureSummary'; import { SettingsAPI } from '@/api/settings'; import { NodesAPI } from '@/api/nodes'; import { UpdatesAPI } from '@/api/updates'; import { Card } from '@/components/shared/Card'; import { SectionHeader } from '@/components/shared/SectionHeader'; import { Toggle } from '@/components/shared/Toggle'; import { formField, labelClass, controlClass, formHelpText } from '@/components/shared/Form'; import Server from 'lucide-solid/icons/server'; import HardDrive from 'lucide-solid/icons/hard-drive'; import Mail from 'lucide-solid/icons/mail'; import Link from 'lucide-solid/icons/link'; import Container from 'lucide-solid/icons/container'; import SettingsIcon from 'lucide-solid/icons/settings'; import Shield from 'lucide-solid/icons/shield'; import Activity from 'lucide-solid/icons/activity'; import type { NodeConfig } from '@/types/nodes'; import type { UpdateInfo, VersionInfo } from '@/api/updates'; import type { APITokenRecord } from '@/api/security'; import type { SecurityStatus as SecurityStatusInfo } from '@/types/config'; import { eventBus } from '@/stores/events'; import { notificationStore } from '@/stores/notifications'; import { showTokenReveal } from '@/stores/tokenReveal'; import { updateStore } from '@/stores/updates'; const COMMON_DISCOVERY_SUBNETS = [ '192.168.1.0/24', '192.168.0.0/24', '10.0.0.0/24', '172.16.0.0/24', '192.168.10.0/24', ]; // Type definitions interface DiscoveredServer { ip: string; port: number; type: 'pve' | 'pbs' | 'pmg'; version: string; hostname?: string; release?: string; } type RawDiscoveredServer = { ip?: string; port?: number; type?: string; version?: string; hostname?: string; name?: string; release?: string; }; interface ClusterEndpoint { Host?: string; IP?: string; } interface DiagnosticsNode { id: string; name: string; host: string; type: string; authMethod: string; connected: boolean; error?: string; details?: Record; lastPoll?: string; clusterInfo?: Record; } interface DiagnosticsPBS { id: string; name: string; host: string; connected: boolean; error?: string; details?: Record; } interface SystemDiagnostic { os: string; arch: string; goVersion: string; numCPU: number; numGoroutine: number; memoryMB: number; } interface TemperatureProxyDiagnostic { legacySSHDetected: boolean; recommendProxyUpgrade: boolean; socketFound: boolean; socketPath?: string; socketPermissions?: string; socketOwner?: string; socketGroup?: string; proxyReachable?: boolean; proxyVersion?: string; proxyPublicKeySha256?: string; proxySshDirectory?: string; legacySshKeyCount?: number; notes?: string[]; } interface APITokenSummary { id: string; name: string; hint?: string; createdAt?: string; lastUsedAt?: string; source?: string; } interface APITokenUsage { tokenId: string; hostCount: number; hosts?: string[]; } interface APITokenDiagnostic { enabled: boolean; tokenCount: number; hasEnvTokens: boolean; hasLegacyToken: boolean; recommendTokenSetup: boolean; recommendTokenRotation: boolean; legacyDockerHostCount?: number; unusedTokenCount?: number; notes?: string[]; tokens?: APITokenSummary[]; usage?: APITokenUsage[]; } interface DockerAgentAttention { hostId: string; name: string; status: string; agentVersion?: string; tokenHint?: string; lastSeen?: string; issues: string[]; } interface DockerAgentDiagnostic { hostsTotal: number; hostsOnline: number; hostsReportingVersion: number; hostsWithTokenBinding: number; hostsWithoutTokenBinding: number; hostsWithoutVersion?: number; hostsOutdatedVersion?: number; hostsWithStaleCommand?: number; hostsPendingUninstall?: number; hostsNeedingAttention: number; recommendedAgentVersion?: string; attention?: DockerAgentAttention[]; notes?: string[]; } interface AlertsDiagnostic { legacyThresholdsDetected: boolean; legacyThresholdSources?: string[]; legacyScheduleSettings?: string[]; missingCooldown: boolean; missingGroupingWindow: boolean; notes?: string[]; } interface ProxyRegisterNode { name: string; sshReady: boolean; error?: string; } interface DockerMigrationResult { token: string; installCommand: string; systemdServiceSnippet: string; pulseURL: string; record: APITokenRecord; } interface DiagnosticsData { version: string; runtime: string; uptime: number; nodes: DiagnosticsNode[]; pbs: DiagnosticsPBS[]; system: SystemDiagnostic; temperatureProxy?: TemperatureProxyDiagnostic | null; apiTokens?: APITokenDiagnostic | null; dockerAgents?: DockerAgentDiagnostic | null; alerts?: AlertsDiagnostic | null; errors: string[]; } interface DiscoveryScanStatus { scanning: boolean; subnet?: string; lastScanStartedAt?: number; lastResultAt?: number; errors?: string[]; } type SettingsTab = | 'pve' | 'pbs' | 'pmg' | 'docker' | 'system' | 'urls' | 'security' | 'diagnostics' | 'updates'; const SETTINGS_HEADER_META: Record = { pve: { title: 'Proxmox VE', description: 'Connect clusters, manage nodes, and control discovery for Proxmox VE.', }, pbs: { title: 'Proxmox Backup Server', description: 'Add and maintain Proxmox Backup Server endpoints used for snapshot and archive monitoring.', }, pmg: { title: 'Proxmox Mail Gateway', description: 'Monitor mail flow, spam trends, and quarantine health from your PMG nodes.', }, docker: { title: 'Docker Agents', description: 'Configure Docker hosts, agent tokens, and polling behaviour for container insights.', }, system: { title: 'System Settings', description: 'Adjust Pulse core behaviour, discovery preferences, and software update cadence.', }, urls: { title: 'Guest URLs', description: 'Define per-guest deep links to console, dashboards, or third-party tooling.', }, security: { title: 'Security & Access', description: 'Manage authentication, API tokens, exports, and other security posture tooling.', }, diagnostics: { title: 'Diagnostics', description: 'Inspect discovery scans, connection health, and runtime metrics for troubleshooting.', }, updates: { title: 'Update History', description: 'Review past software updates, rollback events, and upgrade audit logs.', }, }; const BACKUP_INTERVAL_OPTIONS = [ { label: 'Default (~90 seconds)', value: 0 }, { label: '15 minutes', value: 15 * 60 }, { label: '30 minutes', value: 30 * 60 }, { label: '1 hour', value: 60 * 60 }, { label: '6 hours', value: 6 * 60 * 60 }, { label: '12 hours', value: 12 * 60 * 60 }, { label: '24 hours', value: 24 * 60 * 60 }, ]; const BACKUP_INTERVAL_MAX_MINUTES = 7 * 24 * 60; // 7 days // Node with UI-specific fields type NodeConfigWithStatus = NodeConfig & { hasPassword?: boolean; hasToken?: boolean; status: 'connected' | 'disconnected' | 'offline' | 'error' | 'pending'; }; interface SettingsProps { darkMode: () => boolean; toggleDarkMode: () => void; } const Settings: Component = (props) => { const { state, connected } = useWebSocket(); const navigate = useNavigate(); const location = useLocation(); const deriveTabFromPath = (path: string): SettingsTab => { if (path.includes('/settings/pbs')) return 'pbs'; if (path.includes('/settings/pmg')) return 'pmg'; if (path.includes('/settings/docker')) return 'docker'; if (path.includes('/settings/system')) return 'system'; if (path.includes('/settings/security')) return 'security'; if (path.includes('/settings/diagnostics')) return 'diagnostics'; if (path.includes('/settings/urls')) return 'urls'; if (path.includes('/settings/updates')) return 'updates'; return 'pve'; }; const [currentTab, setCurrentTab] = createSignal(deriveTabFromPath(location.pathname)); const activeTab = () => currentTab(); const setActiveTab = (tab: SettingsTab) => { if (currentTab() !== tab) { setCurrentTab(tab); } const targetPath = `/settings/${tab}`; if (location.pathname !== targetPath) { navigate(targetPath); } }; const headerMeta = () => SETTINGS_HEADER_META[activeTab()] ?? { title: 'Settings', description: 'Manage Pulse configuration.', }; const pveBackupsState = () => state.backups?.pve ?? state.pveBackups; const pbsBackupsState = () => state.backups?.pbs ?? state.pbsBackups; // Keep tab state in sync with URL and handle /settings redirect without flicker createEffect( on( () => location.pathname, (path) => { if (path === '/settings' || path === '/settings/') { if (currentTab() !== 'pve') { setCurrentTab('pve'); } return; } const resolved = deriveTabFromPath(path); if (resolved !== currentTab()) { setCurrentTab(resolved); } }, ), ); const [hasUnsavedChanges, setHasUnsavedChanges] = createSignal(false); const [sidebarCollapsed, setSidebarCollapsed] = createSignal(true); const [nodes, setNodes] = createSignal([]); const [discoveredNodes, setDiscoveredNodes] = createSignal([]); const [showNodeModal, setShowNodeModal] = createSignal(false); const [editingNode, setEditingNode] = createSignal(null); const [currentNodeType, setCurrentNodeType] = createSignal<'pve' | 'pbs' | 'pmg'>('pve'); const [modalResetKey, setModalResetKey] = createSignal(0); const [showPasswordModal, setShowPasswordModal] = createSignal(false); const [showDeleteNodeModal, setShowDeleteNodeModal] = createSignal(false); const [nodePendingDelete, setNodePendingDelete] = createSignal(null); const [deleteNodeLoading, setDeleteNodeLoading] = createSignal(false); const [initialLoadComplete, setInitialLoadComplete] = createSignal(false); const [discoveryScanStatus, setDiscoveryScanStatus] = createSignal({ scanning: false, }); // System settings // PBS polling interval removed - fixed at 10 seconds const [allowedOrigins, setAllowedOrigins] = createSignal('*'); const [discoveryEnabled, setDiscoveryEnabled] = createSignal(false); const [discoverySubnet, setDiscoverySubnet] = createSignal('auto'); const [discoveryMode, setDiscoveryMode] = createSignal<'auto' | 'custom'>('auto'); const [discoverySubnetDraft, setDiscoverySubnetDraft] = createSignal(''); const [lastCustomSubnet, setLastCustomSubnet] = createSignal(''); const [discoverySubnetError, setDiscoverySubnetError] = createSignal(); const [savingDiscoverySettings, setSavingDiscoverySettings] = createSignal(false); const [envOverrides, setEnvOverrides] = createSignal>({}); let discoverySubnetInputRef: HTMLInputElement | undefined; const parseSubnetList = (value: string) => { const seen = new Set(); return value .split(',') .map((token) => token.trim()) .filter((token) => { if (!token || token.toLowerCase() === 'auto' || seen.has(token)) { return false; } seen.add(token); return true; }); }; const normalizeSubnetList = (value: string) => parseSubnetList(value).join(', '); const currentDraftSubnetValue = () => { if (discoveryMode() === 'custom') { return discoverySubnetDraft(); } const draft = discoverySubnetDraft(); if (draft.trim() !== '') { return draft; } const saved = discoverySubnet(); return saved.toLowerCase() === 'auto' ? '' : saved; }; const isValidCIDR = (value: string) => { const subnets = parseSubnetList(value); if (subnets.length === 0) { return false; } return subnets.every((token) => { const [network, prefix] = token.split('/'); if (!network || typeof prefix === 'undefined') { return false; } const prefixNumber = Number(prefix); if (!Number.isInteger(prefixNumber) || prefixNumber < 0 || prefixNumber > 32) { return false; } const octets = network.split('.'); if (octets.length !== 4) { return false; } return octets.every((octet) => { if (octet === '') return false; if (!/^\d+$/.test(octet)) return false; const valueNumber = Number(octet); return valueNumber >= 0 && valueNumber <= 255; }); }); }; const applySavedDiscoverySubnet = (subnet?: string | null) => { const raw = typeof subnet === 'string' ? subnet.trim() : ''; if (raw === '' || raw.toLowerCase() === 'auto') { setDiscoverySubnet('auto'); setDiscoveryMode('auto'); setDiscoverySubnetDraft(''); } else { setDiscoveryMode('custom'); const normalizedValue = normalizeSubnetList(raw); setDiscoverySubnet(normalizedValue); setDiscoverySubnetDraft(normalizedValue); setLastCustomSubnet(normalizedValue); setDiscoverySubnetError(undefined); return; } setDiscoverySubnetError(undefined); }; // Connection timeout removed - backend-only setting // Iframe embedding settings const [allowEmbedding, setAllowEmbedding] = createSignal(false); const [allowedEmbedOrigins, setAllowedEmbedOrigins] = createSignal(''); // Update settings const [versionInfo, setVersionInfo] = createSignal(null); const [updateInfo, setUpdateInfo] = createSignal(null); const [checkingForUpdates, setCheckingForUpdates] = createSignal(false); const [updateChannel, setUpdateChannel] = createSignal<'stable' | 'rc'>('stable'); const [autoUpdateEnabled, setAutoUpdateEnabled] = createSignal(false); const [autoUpdateCheckInterval, setAutoUpdateCheckInterval] = createSignal(24); const [autoUpdateTime, setAutoUpdateTime] = createSignal('03:00'); const [backupPollingEnabled, setBackupPollingEnabled] = createSignal(true); const [backupPollingInterval, setBackupPollingInterval] = createSignal(0); const [backupPollingCustomMinutes, setBackupPollingCustomMinutes] = createSignal(60); const backupPollingEnvLocked = () => Boolean(envOverrides()['ENABLE_BACKUP_POLLING'] || envOverrides()['BACKUP_POLLING_INTERVAL']); const backupIntervalSelectValue = () => { const seconds = backupPollingInterval(); return BACKUP_INTERVAL_OPTIONS.some((option) => option.value === seconds) ? String(seconds) : 'custom'; }; const backupIntervalSummary = () => { if (!backupPollingEnabled()) { return 'Backup polling is disabled.'; } const seconds = backupPollingInterval(); if (seconds <= 0) { return 'Pulse checks backups and snapshots at the default cadence (~every 90 seconds).'; } if (seconds % 86400 === 0) { const days = seconds / 86400; return `Pulse checks backups every ${days === 1 ? 'day' : `${days} days`}.`; } if (seconds % 3600 === 0) { const hours = seconds / 3600; return `Pulse checks backups every ${hours === 1 ? 'hour' : `${hours} hours`}.`; } const minutes = Math.max(1, Math.round(seconds / 60)); return `Pulse checks backups every ${minutes === 1 ? 'minute' : `${minutes} minutes`}.`; }; // Diagnostics const [diagnosticsData, setDiagnosticsData] = createSignal(null); const [runningDiagnostics, setRunningDiagnostics] = createSignal(false); const [proxyActionLoading, setProxyActionLoading] = createSignal<'register-nodes' | null>(null); const [proxyRegisterSummary, setProxyRegisterSummary] = createSignal(null); const [dockerActionLoading, setDockerActionLoading] = createSignal(null); const [dockerMigrationResults, setDockerMigrationResults] = createSignal>({}); // Security const [securityStatus, setSecurityStatus] = createSignal(null); const [securityStatusLoading, setSecurityStatusLoading] = createSignal(true); const [exportPassphrase, setExportPassphrase] = createSignal(''); const [useCustomPassphrase, setUseCustomPassphrase] = createSignal(false); const [importPassphrase, setImportPassphrase] = createSignal(''); const [importFile, setImportFile] = createSignal(null); const [showExportDialog, setShowExportDialog] = createSignal(false); const [showImportDialog, setShowImportDialog] = createSignal(false); const [showApiTokenModal, setShowApiTokenModal] = createSignal(false); const [apiTokenInput, setApiTokenInput] = createSignal(''); const [apiTokenModalSource, setApiTokenModalSource] = createSignal<'export' | 'import' | null>( null, ); const [showQuickSecuritySetup, setShowQuickSecuritySetup] = createSignal(false); const [showQuickSecurityWizard, setShowQuickSecurityWizard] = createSignal(false); const formatRelativeTime = (timestamp?: number) => { if (!timestamp) { return ''; } const delta = Date.now() - timestamp; if (delta < 0) { return 'just now'; } const seconds = Math.round(delta / 1000); if (seconds < 60) { return `${seconds}s ago`; } const minutes = Math.round(seconds / 60); if (minutes < 60) { return `${minutes}m ago`; } const hours = Math.round(minutes / 60); if (hours < 24) { return `${hours}h ago`; } return new Date(timestamp).toLocaleString(); }; const formatUptime = (seconds: number) => { if (!seconds || seconds <= 0) { return 'Unknown'; } const days = Math.floor(seconds / 86400); const hours = Math.floor((seconds % 86400) / 3600); const minutes = Math.floor((seconds % 3600) / 60); if (days > 0) { return `${days}d ${hours}h`; } if (hours > 0) { return `${hours}h ${minutes}m`; } if (minutes > 0) { return `${minutes}m`; } return `${Math.floor(seconds)}s`; }; const runDiagnostics = async () => { setRunningDiagnostics(true); try { const response = await fetch('/api/diagnostics'); const diag = await response.json(); setDiagnosticsData(diag); } catch (err) { console.error('Failed to fetch diagnostics:', err); showError('Failed to run diagnostics'); } finally { setRunningDiagnostics(false); } }; const handleRegisterProxyNodes = async () => { if (proxyActionLoading()) return; setProxyActionLoading('register-nodes'); try { const response = await fetch('/api/diagnostics/temperature-proxy/register-nodes', { method: 'POST', }); const data = await response.json().catch(() => null); if (!response.ok || !data || data.success !== true) { const message = data && typeof data.message === 'string' ? data.message : 'Failed to query proxy nodes'; showError(message); return; } const nodes = Array.isArray(data.nodes) ? (data.nodes as Array>).map((node) => ({ name: typeof node.name === 'string' ? node.name : 'unknown', sshReady: Boolean(node.ssh_ready), error: typeof node.error === 'string' ? node.error : undefined, })) : []; setProxyRegisterSummary(nodes); showSuccess('Queried proxy node registration state'); await runDiagnostics(); } catch (err) { console.error('Failed to query proxy node registration state:', err); showError('Failed to query proxy nodes'); } finally { setProxyActionLoading(null); } }; const handleDockerPrepareToken = async (hostId: string) => { if (dockerActionLoading()) return; setDockerActionLoading(hostId); try { const response = await fetch('/api/diagnostics/docker/prepare-token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hostId }), }); const data = await response.json().catch(() => null); if (!response.ok || !data || data.success !== true) { const message = data && typeof data.message === 'string' ? data.message : 'Failed to prepare Docker token'; showError(message); return; } const recordPayload = data.record as APITokenRecord | undefined; if (!recordPayload || typeof recordPayload.id !== 'string') { showError('Server did not return a valid token record'); return; } const tokenRecord: APITokenRecord = { id: recordPayload.id, name: recordPayload.name, prefix: recordPayload.prefix, suffix: recordPayload.suffix, createdAt: recordPayload.createdAt, lastUsedAt: recordPayload.lastUsedAt, }; const migrationResult: DockerMigrationResult = { token: data.token as string, installCommand: data.installCommand as string, systemdServiceSnippet: data.systemdServiceSnippet as string, pulseURL: data.pulseURL as string, record: tokenRecord, }; setDockerMigrationResults((prev) => ({ ...prev, [hostId]: migrationResult, })); showTokenReveal({ token: migrationResult.token, record: tokenRecord, source: 'docker', note: 'Copy this token into the install command shown in Diagnostics.', }); const hostName = data.host && typeof data.host.name === 'string' ? data.host.name : hostId; showSuccess(`Generated dedicated token for ${hostName}`); await runDiagnostics(); } catch (err) { console.error('Failed to prepare Docker token', err); showError('Failed to prepare Docker token'); } finally { setDockerActionLoading(null); } }; const handleCopy = async (text: string, successMessage: string) => { const success = await copyToClipboard(text); if (success) { showSuccess(successMessage); } else { showError('Failed to copy to clipboard'); } }; const tabGroups: { id: 'proxmox' | 'docker' | 'administration'; label: string; items: { id: SettingsTab; label: string; icon: JSX.Element }[]; }[] = [ { id: 'proxmox', label: 'Proxmox', items: [ { id: 'pve', label: 'Proxmox VE nodes', icon: }, { id: 'pbs', label: 'Proxmox Backup Server', icon: }, { id: 'pmg', label: 'Proxmox Mail Gateway', icon: }, { id: 'urls', label: 'Guest URLs', icon: }, ], }, { id: 'docker', label: 'Docker', items: [{ id: 'docker', label: 'Docker hosts', icon: }], }, { id: 'administration', label: 'Administration', items: [ { id: 'system', label: 'System', icon: }, { id: 'security', label: 'Security', icon: }, { id: 'diagnostics', label: 'Diagnostics', icon: }, ], }, ]; const flatTabs = tabGroups.flatMap((group) => group.items); // Function to load nodes 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; }); setNodes(nodesWithStatus); } catch (error) { console.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')) ) { console.log('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(); console.log('Security status loaded:', status); setSecurityStatus(status); } else { console.error('Failed to fetch security status:', response.status); } } catch (err) { console.error('Failed to fetch security status:', err); } finally { setSecurityStatusLoading(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'; } } 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, })); } 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, })); } } } catch (error) { console.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'); } if (!quiet) { notificationStore.info('Discovery scan started', 2000); } } catch (error) { console.error('Failed to start discovery scan:', error); notificationStore.error('Failed to start discovery scan'); setDiscoveryScanStatus((prev) => ({ ...prev, scanning: false, })); } }; 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; } 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: 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); setDiscoveryScanStatus((prev) => ({ ...prev, scanning: false, })); } return true; } catch (error) { console.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); } return true; } catch (error) { console.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 handleDiscoveryModeChange = async (mode: 'auto' | 'custom') => { if (envOverrides().discoverySubnet || savingDiscoverySettings()) { return; } if (mode === discoveryMode()) { return; } if (mode === 'auto') { const previousSubnet = discoverySubnet(); setDiscoveryMode('auto'); setDiscoverySubnetDraft(''); setDiscoverySubnetError(undefined); setSavingDiscoverySettings(true); try { 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) { console.error('Failed to update discovery subnet:', error); notificationStore.error('Failed to update discovery subnet'); applySavedDiscoverySubnet(previousSubnet); } 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; } 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 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 systemResponse = await fetch('/api/config/system'); if (systemResponse.ok) { const systemSettings = await systemResponse.json(); // PBS polling interval is now fixed at 10 seconds setAllowedOrigins(systemSettings.allowedOrigins || '*'); // Connection timeout is backend-only // Load discovery settings // Backend defaults to false, so we should respect that setDiscoveryEnabled(systemSettings.discoveryEnabled ?? false); // Default to false if undefined applySavedDiscoverySubnet(systemSettings.discoverySubnet); // Load embedding settings setAllowEmbedding(systemSettings.allowEmbedding ?? false); setAllowedEmbedOrigins(systemSettings.allowedEmbedOrigins || ''); // 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))); } // 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); } } else { // Fallback to old endpoint await SettingsAPI.getSettings(); } } catch (error) { console.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 updateStore.checkForUpdates(); // This will load version info too if (version.channel) { setUpdateChannel(version.channel as 'stable' | 'rc'); } } catch (error) { console.error('Failed to load version:', error); } } catch (error) { console.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') { // Save system settings using typed API await SettingsAPI.updateSystemSettings({ // PBS polling interval is now fixed at 10 seconds 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(), }); } showSuccess('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) { showError(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; showSuccess(`${label} removed successfully`); } catch (error) { showError(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') { showSuccess(result.message || 'Connection successful'); } else { throw new Error(result.message || 'Connection failed'); } } catch (error) { showError(error instanceof Error ? error.message : 'Connection test failed'); } }; const checkForUpdates = async () => { setCheckingForUpdates(true); try { // Force check with current channel selection await updateStore.checkForUpdates(true); const info = updateStore.updateInfo(); setUpdateInfo(info); // If update was dismissed, clear it so user can see it again if (info?.available && updateStore.isDismissed()) { updateStore.clearDismissed(); } if (!info?.available) { showSuccess('You are running the latest version'); } } catch (error) { showError('Failed to check for updates'); console.error('Update check error:', error); } finally { setCheckingForUpdates(false); } }; const handleExport = async () => { if (!exportPassphrase()) { const hasAuth = securityStatus()?.hasAuthentication; showError( hasAuth ? useCustomPassphrase() ? 'Please enter a passphrase' : 'Please enter your password' : 'Please enter a passphrase', ); return; } // Just require a passphrase to be entered const hasAuth = securityStatus()?.hasAuthentication; if ((!hasAuth || useCustomPassphrase()) && !exportPassphrase()) { showError('Please enter a passphrase'); 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 && !localStorage.getItem('apiToken') ) { setApiTokenModalSource('export'); setShowApiTokenModal(true); return; } try { // Get CSRF token from cookie const csrfToken = document.cookie .split('; ') .find((row) => row.startsWith('pulse_csrf=')) ?.split('=')[1]; 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 = localStorage.getItem('apiToken'); if (apiToken) { headers['X-API-Token'] = apiToken; } const response = await fetch('/api/config/export', { method: 'POST', headers, credentials: 'include', // Include cookies for session auth body: JSON.stringify({ passphrase: exportPassphrase() }), }); if (!response.ok) { const errorText = await response.text(); // Handle authentication errors if (response.status === 401 || response.status === 403) { // Check if we're using API token auth (not password auth) const hasPasswordAuth = securityStatus()?.hasAuthentication; if (!hasPasswordAuth) { // Clear invalid token if we had one const hadToken = localStorage.getItem('apiToken'); if (hadToken) { localStorage.removeItem('apiToken'); showError('Invalid or expired API token. Please re-enter.'); setApiTokenModalSource('export'); setShowApiTokenModal(true); return; } if (errorText.includes('API_TOKEN') || errorText.includes('API_TOKENS')) { setApiTokenModalSource('export'); setShowApiTokenModal(true); return; } } throw new Error('Export requires authentication'); } throw new Error(errorText || 'Export failed'); } const data = await response.json(); // 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); showSuccess('Configuration exported successfully'); setShowExportDialog(false); setExportPassphrase(''); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Failed to export configuration'; showError(errorMessage); console.error('Export error:', error); } }; const handleImport = async () => { if (!importPassphrase()) { showError('Please enter the password'); return; } if (!importFile()) { showError('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 && !localStorage.getItem('apiToken') ) { setApiTokenModalSource('import'); setShowApiTokenModal(true); return; } try { const fileContent = await importFile()!.text(); let exportData; try { exportData = JSON.parse(fileContent); } catch (parseError) { showError('Invalid JSON file format'); console.error('JSON parse error:', parseError); return; } // Get CSRF token from cookie const csrfToken = document.cookie .split('; ') .find((row) => row.startsWith('pulse_csrf=')) ?.split('=')[1]; 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 = localStorage.getItem('apiToken'); if (apiToken) { headers['X-API-Token'] = apiToken; } const response = await fetch('/api/config/import', { method: 'POST', headers, credentials: 'include', // Include cookies for session auth body: JSON.stringify({ passphrase: importPassphrase(), data: exportData.data, }), }); if (!response.ok) { const errorText = await response.text(); // Handle authentication errors if (response.status === 401 || response.status === 403) { // Check if we're using API token auth (not password auth) const hasPasswordAuth = securityStatus()?.hasAuthentication; if (!hasPasswordAuth) { // Clear invalid token if we had one const hadToken = localStorage.getItem('apiToken'); if (hadToken) { localStorage.removeItem('apiToken'); showError('Invalid or expired API token. Please re-enter.'); setApiTokenModalSource('import'); setShowApiTokenModal(true); return; } if (errorText.includes('API_TOKEN') || errorText.includes('API_TOKENS')) { setApiTokenModalSource('import'); setShowApiTokenModal(true); return; } } throw new Error('Import requires authentication'); } throw new Error(errorText || 'Import failed'); } showSuccess('Configuration imported successfully. Reloading...'); setShowImportDialog(false); setImportPassphrase(''); setImportFile(null); // Reload page to apply new configuration setTimeout(() => window.location.reload(), 2000); } catch (error) { showError('Failed to import configuration'); console.error('Import error:', error); } }; return ( <>
{/* Header with better styling */} {/* Save notification bar - only show when there are unsaved changes */}
You have unsaved changes
setSidebarCollapsed(false)} onMouseLeave={() => setSidebarCollapsed(true)} aria-label="Settings navigation" aria-expanded={!sidebarCollapsed()} >
{(group) => (

{group.label}

{(item) => ( )}
)}
0}>
{(tab) => ( )}
{/* PVE Nodes Tab */}
Loading configuration...
{/* 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'} } />
n.type === 'pve')}> {(node) => (
{ // Find the corresponding node in the WebSocket state const stateNode = state.nodes.find( (n) => n.instance === node.name, ); // Check if the node has an unhealthy connection or is offline if ( stateNode?.connectionHealth === 'unhealthy' || stateNode?.connectionHealth === 'error' || stateNode?.status === 'offline' || stateNode?.status === 'disconnected' ) { return 'bg-red-500'; } // Check if connection is degraded (partial cluster connectivity) if (stateNode?.connectionHealth === 'degraded') { return 'bg-yellow-500'; } // Check if we have a healthy connection if ( stateNode && (stateNode.status === 'online' || stateNode.connectionHealth === 'healthy') ) { return 'bg-green-500'; } // Fall back to the last known config status if live data hasn't arrived yet if (node.status === 'connected') { return 'bg-green-500'; } if ( node.status === 'error' || node.status === 'offline' || node.status === 'disconnected' ) { return 'bg-red-500'; } if (node.status === 'pending') { return 'bg-amber-500 animate-pulse'; } return 'bg-gray-400'; })()}`} >

{node.name}

{node.host}

{node.user ? `User: ${node.user}` : `Token: ${node.tokenName}`} {node.type === 'pve' && 'monitorVMs' in node && node.monitorVMs && ( VMs )} {node.type === 'pve' && 'monitorContainers' in node && node.monitorContainers && ( Containers )} {node.type === 'pve' && 'monitorStorage' in node && node.monitorStorage && ( Storage )} {node.type === 'pve' && 'monitorBackups' in node && node.monitorBackups && ( Backups )} {node.type === 'pve' && 'monitorPhysicalDisks' in node && node.monitorPhysicalDisks && ( Physical Disks )} {node.type === 'pve' && node.temperature?.available && ( Temperature )}
{'clusterName' in node ? node.clusterName : 'Unknown'}{' '} Cluster {'clusterEndpoints' in node && node.clusterEndpoints ? node.clusterEndpoints.length : 0}{' '} nodes
{(endpoint) => (
{endpoint.NodeName} {endpoint.IP}
)}

Automatic failover enabled between cluster nodes

)}
{nodes().filter((n) => n.type === 'pve').length === 0 && discoveredNodes().filter((n) => n.type === 'pve').length === 0 && (

No PVE nodes configured

Add a node to start monitoring

)} {/* 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
)}
{/* PBS Nodes Tab */}
Loading configuration...
{/* 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'} } />
n.type === 'pbs')}> {(node) => (
{ // Find the corresponding PBS instance in the WebSocket state const statePBS = state.pbs.find((p) => p.name === node.name); // Check if the PBS has an unhealthy connection or is offline if ( statePBS?.connectionHealth === 'unhealthy' || statePBS?.connectionHealth === 'error' || statePBS?.status === 'offline' || statePBS?.status === 'disconnected' ) { return 'bg-red-500'; } // Check if connection is degraded (not commonly used for PBS but keeping consistent) if (statePBS?.connectionHealth === 'degraded') { return 'bg-yellow-500'; } // Check if we have a healthy connection if ( statePBS && (statePBS.status === 'online' || statePBS.connectionHealth === 'healthy') ) { return 'bg-green-500'; } // Fall back to the last known config status if live data hasn't arrived yet if (node.status === 'connected') { return 'bg-green-500'; } if ( node.status === 'error' || node.status === 'offline' || node.status === 'disconnected' ) { return 'bg-red-500'; } if (node.status === 'pending') { return 'bg-amber-500 animate-pulse'; } return 'bg-gray-400'; })()}`} >

{node.name}

{node.host}

{node.user ? `User: ${node.user}` : `Token: ${node.tokenName}`} {node.type === 'pbs' && 'monitorDatastores' in node && node.monitorDatastores && ( Datastores )} {node.type === 'pbs' && 'monitorSyncJobs' in node && node.monitorSyncJobs && ( Sync Jobs )} {node.type === 'pbs' && 'monitorVerifyJobs' in node && node.monitorVerifyJobs && ( Verify Jobs )} {node.type === 'pbs' && 'monitorPruneJobs' in node && node.monitorPruneJobs && ( Prune Jobs )} {node.type === 'pbs' && node.temperature?.available && ( Temperature )}
)}
{nodes().filter((n) => n.type === 'pbs').length === 0 && discoveredNodes().filter((n) => n.type === 'pbs').length === 0 && (

No PBS nodes configured

Add a node to start monitoring

)} {/* 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
)}
{/* PMG Nodes Tab */}
Loading configuration...
{/* 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'} } />
n.type === 'pmg')}> {(node) => { const pmgNode = node as NodeConfigWithStatus & { monitorMailStats?: boolean; monitorQueues?: boolean; monitorQuarantine?: boolean; monitorDomainStats?: boolean; }; const statePMG = state.pmg.find((p) => p.name === pmgNode.name); const statusClass = (() => { if ( statePMG?.connectionHealth === 'unhealthy' || statePMG?.connectionHealth === 'error' || statePMG?.status === 'offline' || statePMG?.status === 'disconnected' ) { return 'bg-red-500'; } if (statePMG?.connectionHealth === 'degraded') { return 'bg-yellow-500'; } if (statePMG && (statePMG.status === 'online' || statePMG.connectionHealth === 'healthy')) { return 'bg-green-500'; } if (pmgNode.status === 'connected') { return 'bg-green-500'; } if ( pmgNode.status === 'error' || pmgNode.status === 'offline' || pmgNode.status === 'disconnected' ) { return 'bg-red-500'; } if (pmgNode.status === 'pending') { return 'bg-amber-500 animate-pulse'; } return 'bg-gray-400'; })(); return (

{pmgNode.name}

{pmgNode.host}

{pmgNode.user ? `User: ${pmgNode.user}` : `Token: ${pmgNode.tokenName}`} {pmgNode.monitorMailStats && ( Mail stats )} {pmgNode.monitorQueues && ( Queues )} {pmgNode.monitorQuarantine && ( Quarantine )} {pmgNode.monitorDomainStats && ( Domain stats )}
); }}
{nodes().filter((n) => n.type === 'pmg').length === 0 && (

No PMG nodes configured

Add a node to start monitoring mail flow

)} {/* 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
)}
{/* Docker Tab */} {/* System Settings Tab */}

Configuration Priority

  • • Some env vars override settings (API_TOKENS, legacy API_TOKEN, PORTS, AUTH)
  • • Changes made here are saved to system.json immediately
  • • Settings persist unless overridden by env vars

Automatic scanning

Enable discovery to surface Proxmox VE, PBS, and PMG endpoints automatically.

{ 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'} } />
Scan scope
Common networks: {(preset) => { const baseValue = currentDraftSubnetValue(); const currentSelections = parseSubnetList(baseValue); const isActive = currentSelections.includes(preset); return ( ); }}
{ discoverySubnetInputRef = el; }} type="text" value={discoverySubnetDraft()} placeholder={ discoveryMode() === 'auto' ? 'auto (scan every network phase)' : '192.168.1.0/24, 10.0.0.0/24' } class={`w-full rounded-lg border px-3 py-2 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 ${ envOverrides().discoverySubnet ? 'border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-600 dark:bg-amber-900/20 dark:text-amber-200 cursor-not-allowed opacity-60' : 'border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-900/70' }`} disabled={envOverrides().discoverySubnet} onInput={(e) => { if (envOverrides().discoverySubnet) { return; } const rawValue = e.currentTarget.value; setDiscoverySubnetDraft(rawValue); if (discoveryMode() !== 'custom') { setDiscoveryMode('custom'); } setLastCustomSubnet(rawValue); const trimmed = rawValue.trim(); if (!trimmed) { setDiscoverySubnetError(undefined); return; } if (!isValidCIDR(trimmed)) { setDiscoverySubnetError('Use CIDR format such as 192.168.1.0/24 (comma-separated for multiple)'); } else { setDiscoverySubnetError(undefined); } }} onBlur={async (e) => { if (envOverrides().discoverySubnet || discoveryMode() !== 'custom') { return; } const rawValue = e.currentTarget.value; setDiscoverySubnetDraft(rawValue); const trimmed = rawValue.trim(); if (!trimmed) { setDiscoverySubnetError( 'Enter at least one subnet in CIDR format (e.g., 192.168.1.0/24)', ); return; } if (!isValidCIDR(trimmed)) { setDiscoverySubnetError('Use CIDR format such as 192.168.1.0/24 (comma-separated for multiple)'); return; } setDiscoverySubnetError(undefined); await commitDiscoverySubnet(rawValue); }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); (e.currentTarget as HTMLInputElement).blur(); } }} />

{discoverySubnetError()}

Auto scans every reachable network phase. Large networks may time out — switch to custom subnets to narrow the search.

Example: 192.168.1.0/24, 10.0.0.0/24 (comma-separated). Smaller ranges finish faster and avoid timeouts.

Discovery settings are locked by environment variables. Update the service configuration and restart Pulse to change them here.

Dark mode

Toggle to match your environment. Pulse remembers this preference on each browser.

{ const desired = (event.currentTarget as HTMLInputElement).checked; if (desired !== props.darkMode()) { props.toggleDarkMode(); } }} />

Network Settings

For reverse proxy setups (* = allow all, empty = same-origin only)

{ if (!envOverrides().allowedOrigins) { setAllowedOrigins(e.currentTarget.value); setHasUnsavedChanges(true); } }} disabled={envOverrides().allowedOrigins} placeholder="* or https://example.com" class={`w-full px-3 py-1.5 text-sm border rounded-lg ${ envOverrides().allowedOrigins ? 'border-amber-300 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 cursor-not-allowed opacity-75' : 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800' }`} /> {envOverrides().allowedOrigins && (
Overridden by ALLOWED_ORIGINS environment variable
Remove the env var and restart to enable UI configuration
)}

Embedding

Allow Pulse to be embedded in iframes (e.g., Homepage dashboard)

{ setAllowEmbedding(e.currentTarget.checked); setHasUnsavedChanges(true); }} class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" />

Comma-separated list of origins that can embed Pulse (leave empty for same-origin only)

{ setAllowedEmbedOrigins(e.currentTarget.value); setHasUnsavedChanges(true); }} placeholder="https://my.domain, https://dashboard.example.com" class="w-full px-3 py-1.5 text-sm border rounded-lg border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800" />

Example: If Pulse is at pulse.my.domain and your dashboard is at my.domain, add{' '} https://my.domain here.

Port Configuration: Use{' '} systemctl edit pulse

[Service]
Environment="FRONTEND_PORT=8080"
Then restart: sudo systemctl restart pulse

Updates

{versionInfo()?.version || 'Loading...'} {versionInfo()?.isDevelopment && ' (Development)'} {versionInfo()?.isDocker && ' - Docker'}

Docker Installation: Updates are managed through Docker. Pull the latest image to update.

Built from source: Pull the latest code from git and rebuild to update.

{updateInfo()?.warning}

Update Available: {updateInfo()?.latestVersion}

Released:{' '} {updateInfo()?.releaseDate ? new Date(updateInfo()!.releaseDate).toLocaleDateString() : 'Unknown'}

How to update:

Type{' '} update {' '} in the LXC console

Run these commands:

docker pull rcourtman/pulse:latest
docker restart pulse

Run the install script:

curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash

Pull latest changes and rebuild

Pull the latest Pulse Docker image and recreate your container.

Release Notes
                                  {updateInfo()?.releaseNotes}
                                

Choose between stable and release candidate versions

Automatically check for updates (installation is manual)

How often to check for updates

Preferred time to check for updates

{ setAutoUpdateTime(e.currentTarget.value); setHasUnsavedChanges(true); }} class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800" />
{/* Backup & Restore - Moved from Security tab */}

Backup polling

Control how often Pulse queries Proxmox backup tasks, datastore contents, and guest snapshots. Longer intervals reduce disk activity and API load.

Enable backup polling

Required for dashboard backup status, storage snapshots, and alerting.

{backupIntervalSummary()}

{ const value = Number(e.currentTarget.value); if (Number.isNaN(value)) { return; } const clamped = Math.max( 1, Math.min(BACKUP_INTERVAL_MAX_MINUTES, Math.floor(value)), ); setBackupPollingCustomMinutes(clamped); setBackupPollingInterval(clamped * 60); if (!backupPollingEnvLocked()) { setHasUnsavedChanges(true); } }} class="w-24 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 disabled:opacity-50" /> 1 – {BACKUP_INTERVAL_MAX_MINUTES} minutes (≈7 days max)

Environment override detected

The ENABLE_BACKUP_POLLING or{' '} BACKUP_POLLING_INTERVAL environment variables are set. Remove them and restart Pulse to manage backup polling here.

{/* Export Section */}

Export Configuration

Download an encrypted backup of all nodes and settings

{/* Import Section */}

Restore Configuration

Upload a backup file to restore nodes and settings

Important Notes

  • • Backups contain encrypted credentials and sensitive data
  • • Use a strong passphrase to protect your backup
  • • Store backup files securely and never share the passphrase
{/* Security Tab */}
Proxy authentication detected

Requests are validated by an upstream proxy. The current proxied user is {securityStatus()?.proxyAuthUsername ? ` ${securityStatus()?.proxyAuthUsername}` : ' available once a request is received'} .{securityStatus()?.proxyAuthIsAdmin ? ' Admin privileges confirmed.' : ''} {' '} Proxy logout

Need configuration tips? Review the proxy auth guide in the docs.{' '} Read proxy auth guide →

{/* Show message when auth is disabled */}

Authentication disabled

DISABLE_AUTH is set, or auth isn't configured yet.

{ setShowQuickSecuritySetup(false); loadSecurityStatus(); }} />
{/* Authentication */} {/* Header */}
{/* Content */}
User:{' '} {securityStatus()?.authUsername || 'Not configured'}
{ setShowQuickSecurityWizard(false); loadSecurityStatus(); }} />
{/* Show pending restart message if configured but not loaded */}

Security Configured - Restart Required

Security settings have been configured but the service needs to be restarted to activate them.

After restarting, you'll need to log in with your saved credentials.

How to restart Pulse:

Type{' '} update {' '} in your ProxmoxVE console

Or restart manually with:{' '} systemctl restart pulse

Restart your Docker container:

docker restart pulse

Restart the service:

sudo systemctl restart pulse

Restart the development server:

sudo systemctl restart pulse-hot-dev

Restart Pulse using your deployment method

💡 Tip: Make sure you've saved your credentials before restarting!

{/* Security setup now handled by first-run wizard */} {/* API Token - Show always to allow API access even when auth is disabled */} {/* Header */}
{/* Content */}
{ void loadSecurityStatus(); }} refreshing={securityStatusLoading()} />
{/* Advanced - Only show if auth is enabled */} {/* Advanced Options section removed - was only used for Registration Tokens */}
{/* Diagnostics Tab */}
{/* Live Connection Diagnostics */}

Connection Diagnostics

Test all configured node connections and view detailed status

{/* System Info */}
System
Version: {diagnosticsData()?.version || 'Unknown'}
Runtime: {diagnosticsData()?.runtime || 'Unknown'}
Uptime: {formatUptime(diagnosticsData()?.uptime || 0)}
OS / Arch:{' '} {diagnosticsData()?.system?.os ? `${diagnosticsData()?.system?.os} / ${diagnosticsData()?.system?.arch || 'Unknown'}` : 'Unknown'}
Go runtime: {diagnosticsData()?.system?.goVersion || 'Unknown'}
CPU cores: {diagnosticsData()?.system?.numCPU ?? 'Unknown'}
Goroutines: {diagnosticsData()?.system?.numGoroutine ?? 'Unknown'}
Memory: {diagnosticsData()?.system?.memoryMB ?? 0}{' '} MB
{/* Temperature proxy guidance */} {(temp) => (
Temperature proxy
Proxy socket {temp().socketFound ? 'Detected' : 'Missing'}
Daemon {temp().proxyReachable ? 'Responding' : 'No response'}
Socket path: {temp().socketPath}
Permissions: {temp().socketPermissions}
Owner:{' '} {[temp().socketOwner, temp().socketGroup] .filter(Boolean) .join(' / ') || 'Unknown'}
Proxy version: {temp().proxyVersion}
SSH directory: {temp().proxySshDirectory}
Key fingerprint: {temp().proxyPublicKeySha256}
Legacy SSH keys:{' '} {temp().legacySshKeyCount ?? 0}
Legacy SSH temperature collection detected
0}>
Proxy node connectivity:
    {(node) => (
  • {node.name}: {node.sshReady ? 'reachable' : 'unreachable'} ({node.error})
  • )}
0}>
    {(note) =>
  • {note}
  • }
)}
{/* API token adoption */} {(apiDiag) => (
API tokens
{apiDiag().enabled ? 'Token auth enabled' : 'Token auth disabled'} Env override detected Legacy token present
Configured tokens: {apiDiag().tokenCount}
Rotation needed:{' '} {apiDiag().recommendTokenRotation ? 'Yes' : 'No'}
Docker hosts on shared token:{' '} {apiDiag().legacyDockerHostCount ?? 0}
Unused tokens:{' '} {apiDiag().unusedTokenCount ?? 0}
0}>
{(token) => (
{token.name || 'Unnamed token'}
{token.hint || 'No hint available'} ({token.source})
Created:{' '} {token.createdAt ? new Date(token.createdAt).toLocaleString() : 'Unknown'}
Last used:{' '} {token.lastUsedAt ? formatRelativeTime( new Date(token.lastUsedAt).getTime(), ) : 'Never'}
)}
0}>
Token usage
    {(usage) => (
  • {usage.tokenId}: {usage.hostCount}{' '} {usage.hostCount === 1 ? 'host' : 'hosts'} 0}> ({usage.hosts!.join(', ')})
  • )}
0}>
    {(note) =>
  • {note}
  • }
)}
{/* Docker agent adoption */} {(dockerDiag) => (
Docker agents
0}>
Total hosts: {dockerDiag().hostsTotal}
Online: {dockerDiag().hostsOnline}
With dedicated tokens: {dockerDiag().hostsWithTokenBinding}
Attention required: {dockerDiag().hostsNeedingAttention}
Missing version: {dockerDiag().hostsWithoutVersion ?? 0}
Outdated agents: {dockerDiag().hostsOutdatedVersion ?? 0}
Stale commands: {dockerDiag().hostsWithStaleCommand ?? 0}
Pending uninstall: {dockerDiag().hostsPendingUninstall ?? 0}
Recommended agent version: {dockerDiag().recommendedAgentVersion}
0}>
{(entry) => (
{entry.name} {entry.status || 'unknown'}
Agent {entry.agentVersion} Token {entry.tokenHint} Seen{' '} {formatRelativeTime( new Date(entry.lastSeen!).getTime(), )}
    {(issue) =>
  • {issue}
  • }
{(() => { const migration = dockerMigrationResults()[entry.hostId]!; return ( <>
Install command
                                                      {migration.installCommand}
                                                    
Systemd snippet
                                                      {migration.systemdServiceSnippet}
                                                    
Target URL: {migration.pulseURL}
); })()}
)}
0}>
    {(note) =>
  • {note}
  • }
)}
{/* Alerts configuration */} {(alerts) => (
Alerts configuration
Legacy thresholds {alerts().legacyThresholdsDetected ? 'detected' : 'migrated'} Cooldown {alerts().missingCooldown ? 'missing' : 'configured'} Grouping window {alerts().missingGroupingWindow ? 'disabled' : 'enabled'}
0}>
Legacy sources: {alerts().legacyThresholdSources!.join(', ')}
0}>
Legacy schedule settings: {alerts().legacyScheduleSettings!.join(', ')}
0}>
    {(note) =>
  • {note}
  • }
)}
{/* Nodes Status */} 0} >
PVE Nodes
{(node) => (
{node.name} {node.connected ? 'Connected' : 'Failed'}
Host: {node.host}
Auth: {node.authMethod?.replace('_', ' ')}
{node.error}
)}
{/* PBS Status */} 0}>
PBS Instances
{(pbs) => (
{pbs.name} {pbs.connected ? 'Connected' : 'Failed'}
{pbs.error}
)}
{/* System Information */}

System Information

Version: 2.0.0
Backend: Go 1.21
Frontend: SolidJS + TypeScript
WebSocket Status: {connected() ? 'Connected' : 'Disconnected'}
Server Port: {window.location.port || (window.location.protocol === 'https:' ? '443' : '80')}
{/* Connection Status */}

Connection Status

PVE Nodes: {nodes().filter((n) => n.type === 'pve').length}
PBS Nodes: {nodes().filter((n) => n.type === 'pbs').length}
Total VMs: {state.vms?.length || 0}
Total Containers: {state.containers?.length || 0}
{/* Export Diagnostics */}

Export Diagnostics

Export system diagnostics data for troubleshooting

{/* Helper function to sanitize sensitive data */} {(() => { const sanitizeForGitHub = (data: Record) => { // Deep clone the data const sanitized = JSON.parse(JSON.stringify(data)) as Record< string, unknown >; const connectionKeyMap = new Map(); const instanceKeyMap = new Map(); const getSanitizedInstance = (instance: string) => { if (!instance) return instance; if (instanceKeyMap.has(instance)) { return instanceKeyMap.get(instance)!; } const suffixMatch = instance.match(/\.(lan|local|home|internal)$/); const suffix = suffixMatch ? suffixMatch[0] : ''; const label = `instance-REDACTED${suffix}`; instanceKeyMap.set(instance, label); return label; }; // Sanitize IP addresses (keep first octet for network type identification) const sanitizeIP = (ip: string) => { if (!ip) return ip; const parts = ip.split('.'); if (parts.length === 4) { return `${parts[0]}.xxx.xxx.xxx`; } return 'xxx.xxx.xxx.xxx'; }; // Sanitize hostname but keep domain suffix for context const sanitizeHostname = (hostname: string) => { if (!hostname) return hostname; // Keep common suffixes like .lan, .local, .home const suffixMatch = hostname.match(/\.(lan|local|home|internal)$/); const suffix = suffixMatch ? suffixMatch[0] : ''; return `node-REDACTED${suffix}`; }; const sanitizeText = (text: string | undefined) => { if (!text) return text; return text .replace(/https?:\/\/[^"'\s]+/g, 'https://REDACTED') .replace(/\b\d{1,3}(?:\.\d{1,3}){3}\b/g, 'xxx.xxx.xxx.xxx'); }; const sanitizeNotesArray = (notes: unknown) => { if (!Array.isArray(notes)) return notes; return notes.map((note) => { if (typeof note !== 'string') return note; const sanitizedNote = sanitizeText(note); return sanitizedNote ?? note; }); }; const sanitizeNodeSnapshots = ( snapshots: Array>, ) => snapshots.map((snapshot, index: number) => { const sanitizedSnapshot = { ...snapshot }; const originalNode = typeof sanitizedSnapshot.node === 'string' ? (sanitizedSnapshot.node as string) : ''; if (originalNode) { sanitizedSnapshot.node = connectionKeyMap.get(originalNode) || sanitizeHostname(originalNode); } const originalInstance = typeof sanitizedSnapshot.instance === 'string' ? (sanitizedSnapshot.instance as string) : ''; if (originalInstance) { sanitizedSnapshot.instance = getSanitizedInstance(originalInstance); } if ( typeof sanitizedSnapshot.id === 'string' && sanitizedSnapshot.id ) { sanitizedSnapshot.id = `node-snapshot-${index}`; } return sanitizedSnapshot; }); const sanitizeGuestSnapshots = ( snapshots: Array>, ) => snapshots.map((snapshot, index: number) => { const sanitizedSnapshot = { ...snapshot }; const originalNode = typeof sanitizedSnapshot.node === 'string' ? (sanitizedSnapshot.node as string) : ''; if (originalNode) { sanitizedSnapshot.node = connectionKeyMap.get(originalNode) || sanitizeHostname(originalNode); } const originalInstance = typeof sanitizedSnapshot.instance === 'string' ? (sanitizedSnapshot.instance as string) : ''; if (originalInstance) { sanitizedSnapshot.instance = getSanitizedInstance(originalInstance); } if (typeof sanitizedSnapshot.name === 'string') { sanitizedSnapshot.name = 'vm-REDACTED'; } if (typeof sanitizedSnapshot.vmid === 'number') { sanitizedSnapshot.vmid = index + 1; } else if (typeof sanitizedSnapshot.vmid === 'string') { sanitizedSnapshot.vmid = `vm-${index + 1}`; } if (Array.isArray(sanitizedSnapshot.notes)) { sanitizedSnapshot.notes = sanitizeNotesArray( sanitizedSnapshot.notes, ); } return sanitizedSnapshot; }); // Sanitize nodes if (sanitized.nodes) { sanitized.nodes = ( sanitized.nodes as Array> ).map((node, index: number) => { const nodeType = typeof node.type === 'string' ? (node.type as string) : 'node'; const nodeName = typeof node.name === 'string' ? (node.name as string) : ''; const nodeHost = typeof node.host === 'string' ? (node.host as string) : ''; const tokenName = typeof node.tokenName === 'string' ? (node.tokenName as string) : undefined; const clusterName = typeof node.clusterName === 'string' ? (node.clusterName as string) : undefined; const clusterEndpoints = Array.isArray(node.clusterEndpoints) ? (node.clusterEndpoints as Array>).map( (ep, epIndex: number) => ({ ...ep, NodeName: `node-${epIndex + 1}`, Host: `node-${epIndex + 1}`, IP: sanitizeIP(typeof ep.IP === 'string' ? ep.IP : ''), }), ) : node.clusterEndpoints; const sanitizedId = `${nodeType}-${index}`; connectionKeyMap.set(nodeName, sanitizedId); // Sanitize nested physical disks data if present const physicalDisks = node.physicalDisks as Record< string, unknown > | undefined; if (physicalDisks && Array.isArray(physicalDisks.nodeResults)) { physicalDisks.nodeResults = ( physicalDisks.nodeResults as Array> ).map((result) => ({ ...result, nodeName: sanitizeHostname( typeof result.nodeName === 'string' ? result.nodeName : '', ), apiResponse: sanitizeText( typeof result.apiResponse === 'string' ? result.apiResponse : undefined, ) ?? result.apiResponse, error: sanitizeText( typeof result.error === 'string' ? result.error : undefined, ) ?? result.error, })); } // Sanitize nested VM disk check data if present const vmDiskCheck = node.vmDiskCheck as Record< string, unknown > | undefined; if (vmDiskCheck) { if (typeof vmDiskCheck.testVMName === 'string') { vmDiskCheck.testVMName = 'vm-REDACTED'; } if (typeof vmDiskCheck.testResult === 'string') { vmDiskCheck.testResult = sanitizeText(vmDiskCheck.testResult); } if (Array.isArray(vmDiskCheck.problematicVMs)) { vmDiskCheck.problematicVMs = ( vmDiskCheck.problematicVMs as Array> ).map((problem) => ({ ...problem, name: 'vm-REDACTED', issue: sanitizeText( typeof problem.issue === 'string' ? problem.issue : undefined, ) ?? problem.issue, })); } if (Array.isArray(vmDiskCheck.recommendations)) { vmDiskCheck.recommendations = ( vmDiskCheck.recommendations as Array ).map((rec) => sanitizeText(rec) ?? rec); } } return { ...node, id: sanitizedId, name: sanitizeHostname(nodeName), host: nodeHost ? nodeHost.replace(/https?:\/\/[^:\/]+/, 'https://REDACTED') : nodeHost, tokenName: tokenName ? 'token-REDACTED' : tokenName, clusterName: clusterName ? 'cluster-REDACTED' : clusterName, clusterEndpoints, physicalDisks, vmDiskCheck, }; }); } if (Array.isArray(sanitized.nodeSnapshots)) { sanitized.nodeSnapshots = sanitizeNodeSnapshots( sanitized.nodeSnapshots as Array>, ); } if (Array.isArray(sanitized.guestSnapshots)) { sanitized.guestSnapshots = sanitizeGuestSnapshots( sanitized.guestSnapshots as Array>, ); } if ( sanitized.temperatureProxy && typeof sanitized.temperatureProxy === 'object' ) { const proxyDiag = sanitized.temperatureProxy as Record; if (typeof proxyDiag.socketPath === 'string') { proxyDiag.socketPath = proxyDiag.socketPath.includes( 'pulse-sensor-proxy', ) ? '/mnt/pulse-proxy/pulse-sensor-proxy.sock' : 'proxy-socket'; } if (Array.isArray(proxyDiag.notes)) { proxyDiag.notes = sanitizeNotesArray(proxyDiag.notes); } } if (sanitized.apiTokens && typeof sanitized.apiTokens === 'object') { const apiTokens = sanitized.apiTokens as Record; const tokenIdMap = new Map(); if (Array.isArray(apiTokens.tokens)) { apiTokens.tokens = (apiTokens.tokens as Array>).map( (token, tokenIndex: number) => { const sanitizedToken = { ...token } as Record; const originalId = typeof token.id === 'string' ? (token.id as string) : ''; const sanitizedId = `token-${tokenIndex + 1}`; if (originalId) { tokenIdMap.set(originalId, sanitizedId); } sanitizedToken.id = sanitizedId; sanitizedToken.name = sanitizedId; if (typeof sanitizedToken.hint === 'string') { sanitizedToken.hint = 'token-REDACTED'; } return sanitizedToken; }, ); } if (Array.isArray(apiTokens.usage)) { apiTokens.usage = (apiTokens.usage as Array>).map( (usage, usageIndex: number) => { const sanitizedUsage = { ...usage } as Record; const originalTokenId = typeof usage.tokenId === 'string' ? (usage.tokenId as string) : ''; const mappedId = tokenIdMap.get(originalTokenId) ?? `token-${usageIndex + 1}`; sanitizedUsage.tokenId = mappedId; if (Array.isArray(sanitizedUsage.hosts)) { sanitizedUsage.hosts = (sanitizedUsage.hosts as Array).map( (host, hostIndex) => sanitizeHostname( typeof host === 'string' ? host : `host-${hostIndex + 1}`, ), ); } return sanitizedUsage; }, ); } if (Array.isArray(apiTokens.notes)) { apiTokens.notes = sanitizeNotesArray(apiTokens.notes); } } if (sanitized.dockerAgents && typeof sanitized.dockerAgents === 'object') { const dockerDiag = sanitized.dockerAgents as Record; if (Array.isArray(dockerDiag.attention)) { dockerDiag.attention = ( dockerDiag.attention as Array> ).map((entry, index: number) => { const sanitizedEntry = { ...entry }; sanitizedEntry.hostId = `docker-host-${index + 1}`; sanitizedEntry.name = `docker-host-${index + 1}`; if (typeof sanitizedEntry.tokenHint === 'string') { sanitizedEntry.tokenHint = 'token-REDACTED'; } if (Array.isArray(sanitizedEntry.issues)) { sanitizedEntry.issues = sanitizeNotesArray( sanitizedEntry.issues, ); } return sanitizedEntry; }); } if (Array.isArray(dockerDiag.notes)) { dockerDiag.notes = sanitizeNotesArray(dockerDiag.notes); } } if (sanitized.alerts && typeof sanitized.alerts === 'object') { const alerts = sanitized.alerts as Record; if (Array.isArray(alerts.legacyThresholdSources)) { alerts.legacyThresholdSources = (alerts.legacyThresholdSources as string[]).map((source) => sanitizeText(source) ?? source); } if (Array.isArray(alerts.legacyScheduleSettings)) { alerts.legacyScheduleSettings = (alerts.legacyScheduleSettings as string[]).map((setting) => sanitizeText(setting) ?? setting); } if (Array.isArray(alerts.notes)) { alerts.notes = sanitizeNotesArray(alerts.notes); } } // Sanitize backend diagnostics (if present) if ( sanitized.backendDiagnostics && typeof sanitized.backendDiagnostics === 'object' ) { const backend = sanitized.backendDiagnostics as Record; if (Array.isArray(backend.nodeSnapshots)) { backend.nodeSnapshots = sanitizeNodeSnapshots( backend.nodeSnapshots as Array>, ); } if (Array.isArray(backend.guestSnapshots)) { backend.guestSnapshots = sanitizeGuestSnapshots( backend.guestSnapshots as Array>, ); } if (Array.isArray(backend.nodes)) { backend.nodes = backend.nodes.map((rawNode, index: number) => { const node = rawNode as Record; const originalName = typeof node.name === 'string' ? node.name : ''; const sanitizedId = `diagnostic-node-${index}`; connectionKeyMap.set(originalName, sanitizedId); const vmDiskCheck = node.vmDiskCheck as Record | undefined; const physicalDisks = node.physicalDisks as Record< string, unknown > | undefined; if (vmDiskCheck) { if (typeof vmDiskCheck.testVMName === 'string') { vmDiskCheck.testVMName = 'vm-REDACTED'; } if (typeof vmDiskCheck.testResult === 'string') { const sanitizedResult = sanitizeText( vmDiskCheck.testResult as string, ); vmDiskCheck.testResult = sanitizedResult ?? vmDiskCheck.testResult; } if (Array.isArray(vmDiskCheck.problematicVMs)) { vmDiskCheck.problematicVMs = ( vmDiskCheck.problematicVMs as Array> ).map((problem) => { const issueText = sanitizeText( typeof problem.issue === 'string' ? problem.issue : undefined, ); return { ...problem, name: 'vm-REDACTED', issue: issueText ?? problem.issue, }; }); } if (Array.isArray(vmDiskCheck.recommendations)) { vmDiskCheck.recommendations = ( vmDiskCheck.recommendations as Array ).map((rec) => sanitizeText(rec) ?? rec); } } if (physicalDisks) { if (typeof physicalDisks.testResult === 'string') { const sanitizedResult = sanitizeText( physicalDisks.testResult as string, ); physicalDisks.testResult = sanitizedResult ?? physicalDisks.testResult; } if (Array.isArray(physicalDisks.nodeResults)) { physicalDisks.nodeResults = ( physicalDisks.nodeResults as Array> ).map((result) => ({ ...result, nodeName: sanitizeHostname( typeof result.nodeName === 'string' ? result.nodeName : '', ), apiResponse: sanitizeText( typeof result.apiResponse === 'string' ? result.apiResponse : undefined, ) ?? result.apiResponse, error: sanitizeText( typeof result.error === 'string' ? result.error : undefined, ) ?? result.error, })); } } return { ...node, id: sanitizedId, name: sanitizeHostname(originalName), host: typeof node.host === 'string' ? (node.host as string).replace( /https?:\/\/[^:\/]+/, 'https://REDACTED', ) : node.host, error: sanitizeText( typeof node.error === 'string' ? node.error : undefined, ) ?? node.error, vmDiskCheck, physicalDisks, }; }); } if (Array.isArray(backend.pbs)) { backend.pbs = backend.pbs.map((rawPbs, index: number) => { const pbsNode = rawPbs as Record; const originalName = typeof pbsNode.name === 'string' ? pbsNode.name : ''; const sanitizedId = `diagnostic-pbs-${index}`; connectionKeyMap.set(originalName, sanitizedId); return { ...pbsNode, id: sanitizedId, name: sanitizeHostname(originalName), host: typeof pbsNode.host === 'string' ? (pbsNode.host as string).replace( /https?:\/\/[^:\/]+/, 'https://REDACTED', ) : pbsNode.host, error: sanitizeText( typeof pbsNode.error === 'string' ? pbsNode.error : undefined, ) ?? pbsNode.error, }; }); } } // Sanitize storage if (sanitized.storage) { sanitized.storage = ( sanitized.storage as Array> ).map((s, index: number) => { const storageNode = typeof s.node === 'string' ? s.node : ''; return { ...s, id: `storage-${index}`, node: sanitizeHostname(storageNode), name: `storage-${index}`, }; }); } // Sanitize backups const backups = sanitized.backups as Record | undefined; if (backups) { // Sanitize PVE backup tasks if (Array.isArray(backups.pveBackupTasks)) { backups.pveBackupTasks = ( backups.pveBackupTasks as Array> ).map((b, index: number) => { const backupNode = typeof b.node === 'string' ? b.node : ''; const backupVmid = typeof b.vmid === 'number' ? b.vmid : undefined; return { ...b, node: sanitizeHostname(backupNode), storage: `storage-${index}`, vmid: backupVmid !== undefined ? `vm-${backupVmid}` : backupVmid, }; }); } // Sanitize PVE storage backups if (Array.isArray(backups.pveStorageBackups)) { backups.pveStorageBackups = ( backups.pveStorageBackups as Array> ).map((b, index: number) => { const backupNode = typeof b.node === 'string' ? b.node : ''; const backupVmid = typeof b.vmid === 'number' ? b.vmid : undefined; const volid = typeof b.volid === 'string' ? b.volid : undefined; return { ...b, node: sanitizeHostname(backupNode), storage: `storage-${index}`, vmid: backupVmid !== undefined ? `vm-${backupVmid}` : backupVmid, volid: volid ? 'vol-REDACTED' : volid, }; }); } // Sanitize PBS backups if (Array.isArray(backups.pbsBackups)) { backups.pbsBackups = ( backups.pbsBackups as Array> ).map((b, index: number) => { const backupId = typeof b.backupId === 'string' ? b.backupId : undefined; const vmName = typeof b.vmName === 'string' ? b.vmName : undefined; return { ...b, datastore: `datastore-${index}`, backupId: backupId ? `backup-${index}` : backupId, vmName: vmName ? 'vm-REDACTED' : vmName, }; }); } } // Sanitize active alerts const activeAlerts = sanitized.activeAlerts as | Array> | undefined; if (activeAlerts) { sanitized.activeAlerts = activeAlerts.map((alert) => { const alertNode = typeof alert.node === 'string' ? alert.node : ''; const details = typeof alert.details === 'string' ? alert.details : undefined; return { ...alert, node: sanitizeHostname(alertNode), details: details ? details.replace( /\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/g, 'xxx.xxx.xxx.xxx', ) : details, }; }); } // Sanitize websocket URL const websocketInfo = sanitized.websocket as | Record | undefined; if (websocketInfo && typeof websocketInfo.url === 'string') { websocketInfo.url = websocketInfo.url.replace( /\/\/[^\/]+/, '//REDACTED', ); } if ( sanitized.connectionHealth && typeof sanitized.connectionHealth === 'object' ) { const newConnectionHealth: Record = {}; let index = 1; Object.entries( sanitized.connectionHealth as Record, ).forEach(([key, value]) => { const mappedKey = connectionKeyMap.get(key) || `resource-${index}`; newConnectionHealth[mappedKey] = value; index += 1; }); sanitized.connectionHealth = newConnectionHealth; } // Add sanitization notice sanitized._notice = 'This diagnostic data has been sanitized for sharing on GitHub. IP addresses, hostnames, and tokens have been redacted.'; return sanitized; }; const exportDiagnostics = (sanitize: boolean) => { let diagnostics: Record = { timestamp: new Date().toISOString(), version: '2.1.0', pulseVersion: state.stats?.version || 'unknown', environment: { userAgent: navigator.userAgent, platform: navigator.platform, language: navigator.language, screenResolution: `${window.screen.width}x${window.screen.height}`, windowSize: `${window.innerWidth}x${window.innerHeight}`, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, }, websocket: { connected: connected(), url: `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`, }, // Include backend diagnostics if available backendDiagnostics: diagnosticsData() || null, nodes: nodes()?.map((n) => ({ ...n, status: state.nodes?.find((sn) => sn.id === n.id)?.status || 'unknown', online: state.nodes?.find((sn) => sn.id === n.id)?.status === 'online', })) || [], state: { nodesCount: state.nodes?.length || 0, nodesOnline: state.nodes?.filter((n) => n.status === 'online').length || 0, nodesOffline: state.nodes?.filter((n) => n.status !== 'online').length || 0, vmsCount: state.vms?.length || 0, containersCount: state.containers?.length || 0, storageCount: state.storage?.length || 0, physicalDisksCount: state.physicalDisks?.length || 0, pbsCount: state.pbs?.length || 0, pbsBackupsCount: pbsBackupsState()?.length || 0, pveBackups: { backupTasksCount: pveBackupsState()?.backupTasks?.length || 0, storageBackupsCount: pveBackupsState()?.storageBackups?.length || 0, guestSnapshotsCount: pveBackupsState()?.guestSnapshots?.length || 0, }, }, // Node status details nodeStatus: state.nodes?.map((n) => ({ id: n.id, name: n.name, status: n.status, online: n.status === 'online', cpu: n.cpu, memory: n.memory, uptime: n.uptime, version: n.pveVersion ?? n.kernelVersion, })) || [], storage: state.storage?.map((s) => ({ id: s.id, node: s.node, name: s.name, type: s.type, status: s.status, enabled: s.enabled, content: s.content, shared: s.shared, used: s.used, total: s.total, zfsPool: s.zfsPool ? { state: s.zfsPool.state, readErrors: s.zfsPool.readErrors, writeErrors: s.zfsPool.writeErrors, checksumErrors: s.zfsPool.checksumErrors, deviceCount: s.zfsPool.devices?.length || 0, } : undefined, hasBackups: (pveBackupsState()?.storageBackups ?? []).filter( (b) => b.storage === s.name, ).length > 0, })) || [], // Physical disks - critical for troubleshooting physicalDisks: state.physicalDisks?.map((d) => ({ node: d.node, device: d.device || d.devPath, model: d.model, size: d.size, type: d.type, health: d.health, wearout: d.wearout, rpm: d.rpm, smart: d.smart ?? null, })) || [], backups: { pveBackupTasks: pveBackupsState()?.backupTasks?.slice(0, 10) || [], pveStorageBackups: pveBackupsState()?.storageBackups?.slice(0, 10) || [], pbsBackups: pbsBackupsState()?.slice(0, 10) || [], }, connectionHealth: state.connectionHealth || {}, performance: { lastPollDuration: state.performance?.lastPollDuration || 0, totalApiCalls: state.performance?.totalApiCalls || 0, failedApiCalls: state.performance?.failedApiCalls || 0, apiCallDuration: state.performance?.apiCallDuration || {}, }, activeAlerts: state.activeAlerts?.slice(0, 20) || [], settings: {}, }; if (sanitize) { diagnostics = sanitizeForGitHub(diagnostics); // Rebuild nodeStatus from sanitized nodes to avoid leaking real names // Both arrays are built in the same order, so we can match by index if ( Array.isArray(diagnostics.nodes) && Array.isArray(diagnostics.nodeStatus) ) { const sanitizedNodes = diagnostics.nodes as Array< Record >; const originalNodeStatus = diagnostics.nodeStatus as Array< Record >; diagnostics.nodeStatus = sanitizedNodes.map((sanitizedNode, index) => { // Get corresponding runtime data using same index const originalStatus = originalNodeStatus[index]; // Use sanitized node name/id but preserve runtime metrics return { id: sanitizedNode.id, name: sanitizedNode.name, // Already sanitized status: originalStatus?.status || 'unknown', online: originalStatus?.online || false, cpu: originalStatus?.cpu, memory: originalStatus?.memory, uptime: originalStatus?.uptime, version: originalStatus?.version, }; }); } // Sanitize storage entries that have nodes arrays or instance fields if (Array.isArray(diagnostics.storage)) { diagnostics.storage = ( diagnostics.storage as Array> ).map((storageItem, index) => { const sanitizedItem = { ...storageItem }; // Sanitize storage name field (original Proxmox storage name) // This field often contains node names (e.g., "pbs-delly") if ( typeof sanitizedItem.storage === 'string' && sanitizedItem.storage ) { sanitizedItem.storage = `storage-${index}`; } // Sanitize nodes array if present (cluster storage has this) if (Array.isArray(sanitizedItem.nodes)) { sanitizedItem.nodes = ( sanitizedItem.nodes as Array ).map(() => 'node-REDACTED'); } // Sanitize nodeIds array if present if (Array.isArray(sanitizedItem.nodeIds)) { sanitizedItem.nodeIds = ( sanitizedItem.nodeIds as Array ).map(() => 'node-REDACTED'); } // Sanitize instance field if present if ( typeof sanitizedItem.instance === 'string' && sanitizedItem.instance ) { const instanceMatch = ( sanitizedItem.instance as string ).match(/\.(lan|local|home|internal)$/); const suffix = instanceMatch ? instanceMatch[0] : ''; sanitizedItem.instance = `instance-REDACTED${suffix}`; } return sanitizedItem; }); } // Sanitize activeAlerts resourceName field if (Array.isArray(diagnostics.activeAlerts)) { diagnostics.activeAlerts = ( diagnostics.activeAlerts as Array> ).map((alert, index) => { const sanitizedAlert = { ...alert }; // Sanitize resourceName if present if ( typeof sanitizedAlert.resourceName === 'string' && sanitizedAlert.resourceName ) { const hostnameMatch = ( sanitizedAlert.resourceName as string ).match(/\.(lan|local|home|internal)$/); const suffix = hostnameMatch ? hostnameMatch[0] : ''; sanitizedAlert.resourceName = `resource-REDACTED${suffix}`; } // Sanitize node field if present if ( typeof sanitizedAlert.node === 'string' && sanitizedAlert.node ) { sanitizedAlert.node = 'node-REDACTED'; } // Sanitize instance field if present if ( typeof sanitizedAlert.instance === 'string' && sanitizedAlert.instance ) { const instanceMatch = ( sanitizedAlert.instance as string ).match(/\.(lan|local|home|internal)$/); const suffix = instanceMatch ? instanceMatch[0] : ''; sanitizedAlert.instance = `instance-REDACTED${suffix}`; } // Sanitize resourceId (e.g., "delly.lan-delly" → "alert-resource-0") if ( typeof sanitizedAlert.resourceId === 'string' && sanitizedAlert.resourceId ) { sanitizedAlert.resourceId = `alert-resource-${index}`; } // Sanitize id field (e.g., "delly.lan-delly-temperature" → "alert-0-temperature") if (typeof sanitizedAlert.id === 'string' && sanitizedAlert.id) { // Extract the alert type from the end (e.g., "temperature") const idParts = (sanitizedAlert.id as string).split('-'); const alertType = idParts[idParts.length - 1]; sanitizedAlert.id = `alert-${index}-${alertType}`; } return sanitizedAlert; }); } } const blob = new Blob([JSON.stringify(diagnostics, null, 2)], { type: 'application/json', }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; const type = sanitize ? 'sanitized' : 'full'; a.download = `pulse-diagnostics-${type}-${new Date().toISOString().split('T')[0]}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }; return (
💡 Run diagnostics first for more comprehensive export data
); })()}

Export Full: Complete data for private troubleshooting
Export for GitHub: Sanitized data safe for public sharing

{/* Guest URLs Tab */}
{/* Delete Node Modal */}

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.
{/* Node Modal - Use separate modals for PVE and PBS to ensure clean state */} { 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} 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, ), ); showSuccess('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); showSuccess('Node added successfully'); } setShowNodeModal(false); setEditingNode(null); } catch (error) { showError(error instanceof Error ? error.message : 'Operation failed'); } }} /> {/* PBS Node Modal - Separate instance to prevent contamination */} { 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} 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, ), ); showSuccess('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); showSuccess('Node added successfully'); } setShowNodeModal(false); setEditingNode(null); } catch (error) { showError(error instanceof Error ? error.message : 'Operation failed'); } }} /> {/* PMG Node Modal */} { setShowNodeModal(false); setEditingNode(null); setModalResetKey((prev) => prev + 1); }} nodeType="pmg" editingNode={editingNode()?.type === 'pmg' ? (editingNode() ?? undefined) : undefined} securityStatus={securityStatus() ?? undefined} 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, ), ); showSuccess('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); showSuccess('Node added successfully'); } setShowNodeModal(false); setEditingNode(null); } catch (error) { showError(error instanceof Error ? error.message : 'Operation failed'); } }} /> {/* Export Dialog */}
{/* Password Choice Section - Only show if auth is enabled */}
{/* Show password input based on selection */}
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.
{/* API Token Modal */}

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