import { Show, For, Suspense, lazy, createSignal, createContext, useContext, createEffect, createMemo, onCleanup, onMount, getOwner, runWithOwner, } from 'solid-js'; import type { JSX } from 'solid-js'; import { Router, Route, Navigate, useNavigate, useLocation } from '@solidjs/router'; import { getGlobalWebSocketStore } from './stores/websocket-global'; import { ToastContainer } from './components/Toast/Toast'; import { ErrorBoundary } from './components/ErrorBoundary'; import { SecurityWarning } from './components/SecurityWarning'; import { Login } from './components/Login'; import { logger } from './utils/logger'; import { POLLING_INTERVALS } from './constants'; import { STORAGE_KEYS } from '@/utils/localStorage'; import { UpdatesAPI } from './api/updates'; import type { VersionInfo } from './api/updates'; import { apiFetch } from './utils/apiClient'; import { SettingsAPI } from './api/settings'; import { eventBus } from './stores/events'; import { updateStore } from './stores/updates'; import { UpdateBanner } from './components/UpdateBanner'; import { DemoBanner } from './components/DemoBanner'; import { createTooltipSystem } from './components/shared/Tooltip'; import type { State } from '@/types/api'; import { ProxmoxIcon } from '@/components/icons/ProxmoxIcon'; import BoxesIcon from 'lucide-solid/icons/boxes'; import MonitorIcon from 'lucide-solid/icons/monitor'; import BellIcon from 'lucide-solid/icons/bell'; import SettingsIcon from 'lucide-solid/icons/settings'; import { TokenRevealDialog } from './components/TokenRevealDialog'; import { useAlertsActivation } from './stores/alertsActivation'; const Dashboard = lazy(() => import('./components/Dashboard/Dashboard').then((module) => ({ default: module.Dashboard })), ); const StorageComponent = lazy(() => import('./components/Storage/Storage')); const Backups = lazy(() => import('./components/Backups/Backups')); const Replication = lazy(() => import('./components/Replication/Replication')); const MailGateway = lazy(() => import('./components/PMG/MailGateway')); const AlertsPage = lazy(() => import('./pages/Alerts').then((module) => ({ default: module.Alerts })), ); const SettingsPage = lazy(() => import('./components/Settings/Settings')); const DockerHosts = lazy(() => import('./components/Docker/DockerHosts').then((module) => ({ default: module.DockerHosts })), ); const HostsOverview = lazy(() => import('./components/Hosts/HostsOverview').then((module) => ({ default: module.HostsOverview, })), ); // Enhanced store type with proper typing type EnhancedStore = ReturnType; // Export WebSocket context for other components export const WebSocketContext = createContext(); export const useWebSocket = () => { const context = useContext(WebSocketContext); if (!context) { throw new Error('useWebSocket must be used within WebSocketContext.Provider'); } return context; }; // Dark mode context for reactive theme switching export const DarkModeContext = createContext<() => boolean>(() => false); export const useDarkMode = () => { const context = useContext(DarkModeContext); if (!context) { throw new Error('useDarkMode must be used within DarkModeContext.Provider'); } return context; }; // Docker route component that properly uses activeAlerts from useWebSocket function DockerRoute() { const wsContext = useContext(WebSocketContext); if (!wsContext) { return
Loading...
; } const { state, activeAlerts } = wsContext; const hosts = createMemo(() => state.dockerHosts ?? []); return ; } function HostsRoute() { const wsContext = useContext(WebSocketContext); if (!wsContext) { return
Loading...
; } const { state } = wsContext; return ( ); } function App() { const TooltipRoot = createTooltipSystem(); const owner = getOwner(); const acquireWsStore = (): EnhancedStore => { const store = owner ? runWithOwner(owner, () => getGlobalWebSocketStore()) : getGlobalWebSocketStore(); return store || getGlobalWebSocketStore(); }; const alertsActivation = useAlertsActivation(); let hasPreloadedRoutes = false; let hasFetchedVersionInfo = false; const preloadLazyRoutes = () => { if (hasPreloadedRoutes || typeof window === 'undefined') { return; } hasPreloadedRoutes = true; const loaders: Array<() => Promise> = [ () => import('./components/Storage/Storage'), () => import('./components/Backups/Backups'), () => import('./components/Replication/Replication'), () => import('./components/PMG/MailGateway'), () => import('./components/Hosts/HostsOverview'), () => import('./pages/Alerts'), () => import('./components/Settings/Settings'), () => import('./components/Docker/DockerHosts'), ]; loaders.forEach((load) => { void load().catch((error) => { logger.warn('Preloading route module failed', error); }); }); }; const fallbackState: State = { nodes: [], vms: [], containers: [], dockerHosts: [], hosts: [], storage: [], cephClusters: [], physicalDisks: [], pbs: [], pmg: [], replicationJobs: [], metrics: [], pveBackups: { backupTasks: [], storageBackups: [], guestSnapshots: [], }, pbsBackups: [], pmgBackups: [], backups: { pve: { backupTasks: [], storageBackups: [], guestSnapshots: [], }, pbs: [], pmg: [], }, performance: { apiCallDuration: {}, lastPollDuration: 0, pollingStartTime: '', totalApiCalls: 0, failedApiCalls: 0, cacheHits: 0, cacheMisses: 0, }, connectionHealth: {}, stats: { startTime: new Date().toISOString(), uptime: 0, pollingCycles: 0, webSocketClients: 0, version: '0.0.0', }, activeAlerts: [], recentlyResolved: [], lastUpdate: '', }; // Simple auth state const [isLoading, setIsLoading] = createSignal(true); const [needsAuth, setNeedsAuth] = createSignal(false); const [hasAuth, setHasAuth] = createSignal(false); const [proxyAuthInfo, setProxyAuthInfo] = createSignal<{ username?: string; logoutURL?: string; } | null>(null); // Don't initialize WebSocket until after auth check const [wsStore, setWsStore] = createSignal(null); const state = (): State => wsStore()?.state || fallbackState; const connected = () => wsStore()?.connected() || false; const reconnecting = () => wsStore()?.reconnecting() || false; // Data update indicator const [dataUpdated, setDataUpdated] = createSignal(false); let updateTimeout: number; // Last update time formatting const [lastUpdateText, setLastUpdateText] = createSignal(''); const formatLastUpdate = (timestamp: string) => { if (!timestamp) return ''; const date = new Date(timestamp); return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', second: '2-digit', hour12: true }); }; // Flash indicator when data updates createEffect(() => { // Watch for state changes const updateTime = state().lastUpdate; if (updateTime && updateTime !== '') { setDataUpdated(true); setLastUpdateText(formatLastUpdate(updateTime)); window.clearTimeout(updateTimeout); updateTimeout = window.setTimeout(() => setDataUpdated(false), POLLING_INTERVALS.DATA_FLASH); } }); createEffect(() => { if (!isLoading() && !needsAuth()) { if (typeof window === 'undefined') { return; } if (!hasPreloadedRoutes) { // Defer to the next tick so we don't contend with initial render window.setTimeout(preloadLazyRoutes, 0); } } }); createEffect(() => { if (isLoading() || needsAuth() || hasFetchedVersionInfo) { return; } hasFetchedVersionInfo = true; UpdatesAPI.getVersion() .then((version) => { setVersionInfo(version); // Check for updates after loading version info (non-blocking) updateStore.checkForUpdates(); }) .catch((error) => { logger.error('Failed to load version', error); }); }); let alertsInitialized = false; createEffect(() => { const ready = !isLoading() && !needsAuth(); if (ready && !alertsInitialized) { alertsInitialized = true; void alertsActivation.refreshConfig(); void alertsActivation.refreshActiveAlerts(); } if (!ready) { alertsInitialized = false; } }); // No longer need tab state management - using router now // Version info const [versionInfo, setVersionInfo] = createSignal(null); // Dark mode - initialize immediately from localStorage to prevent flash // This addresses issue #443 where dark mode wasn't persisting // Priority: 1. localStorage (user's last choice on this device) // 2. System preference // 3. Server preference (loaded later for cross-device sync) const savedDarkMode = localStorage.getItem(STORAGE_KEYS.DARK_MODE); const hasLocalPreference = savedDarkMode !== null; const initialDarkMode = hasLocalPreference ? savedDarkMode === 'true' : window.matchMedia('(prefers-color-scheme: dark)').matches; const [darkMode, setDarkMode] = createSignal(initialDarkMode); const [, setHasLoadedServerTheme] = createSignal(false); // Apply dark mode immediately on initialization if (initialDarkMode) { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } // Toggle dark mode const toggleDarkMode = async () => { const newMode = !darkMode(); setDarkMode(newMode); localStorage.setItem(STORAGE_KEYS.DARK_MODE, String(newMode)); if (newMode) { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } logger.info('Theme changed', { mode: newMode ? 'dark' : 'light' }); // Save theme preference to server if authenticated if (!needsAuth()) { try { await SettingsAPI.updateSystemSettings({ theme: newMode ? 'dark' : 'light' }); logger.info('Theme preference saved to server'); } catch (error) { logger.error('Failed to save theme preference to server', error); // Don't show error to user - local change still works } } }; // Don't initialize dark mode here - will be handled based on auth state // Listen for theme changes from other browser instances onMount(() => { const handleThemeChange = (theme?: string) => { if (!theme) return; logger.info('Received theme change from another browser instance', { theme }); const isDark = theme === 'dark'; // Update local state setDarkMode(isDark); localStorage.setItem(STORAGE_KEYS.DARK_MODE, String(isDark)); // Update DOM if (isDark) { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } }; // Subscribe to theme change events eventBus.on('theme_changed', handleThemeChange); // Cleanup on unmount onCleanup(() => { eventBus.off('theme_changed', handleThemeChange); }); }); // Check auth on mount onMount(async () => { logger.debug('[App] Starting auth check...'); // Check if we just logged out - if so, always show login page const justLoggedOut = localStorage.getItem('just_logged_out'); if (justLoggedOut) { localStorage.removeItem('just_logged_out'); logger.debug('[App] User logged out, showing login page'); setHasAuth(true); // Force showing login instead of setup setNeedsAuth(true); setIsLoading(false); return; } // First check security status to see if auth is configured try { const securityRes = await apiFetch('/api/security/status'); if (securityRes.status === 401) { logger.warn( '[App] Security status request returned 401. Clearing stored credentials and showing login.', ); try { const { clearAuth } = await import('./utils/apiClient'); clearAuth(); } catch (clearError) { logger.warn('[App] Failed to clear stored auth after 401', clearError); } setHasAuth(false); setNeedsAuth(true); return; } if (!securityRes.ok) { throw new Error(`Security status request failed with status ${securityRes.status}`); } const securityData = await securityRes.json(); logger.debug('[App] Security status fetched', securityData); // Detect legacy DISABLE_AUTH flag (now ignored) so we can surface a warning if (securityData.deprecatedDisableAuth === true) { logger.warn( '[App] Legacy DISABLE_AUTH flag detected; authentication remains enabled. Remove the flag and restart Pulse to silence this warning.', ); } const authConfigured = securityData.hasAuthentication || false; setHasAuth(authConfigured); // Check for proxy auth if (securityData.hasProxyAuth && securityData.proxyAuthUsername) { logger.info('[App] Proxy auth detected', { user: securityData.proxyAuthUsername }); setProxyAuthInfo({ username: securityData.proxyAuthUsername, logoutURL: securityData.proxyAuthLogoutURL, }); setNeedsAuth(false); // Initialize WebSocket for proxy auth users setWsStore(acquireWsStore()); // Load theme preference from server for cross-device sync // Only use server preference if no local preference exists if (!hasLocalPreference) { try { const systemSettings = await SettingsAPI.getSystemSettings(); if (systemSettings.theme && systemSettings.theme !== '') { const prefersDark = systemSettings.theme === 'dark'; setDarkMode(prefersDark); localStorage.setItem(STORAGE_KEYS.DARK_MODE, String(prefersDark)); if (prefersDark) { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } } setHasLoadedServerTheme(true); } catch (error) { logger.error('Failed to load theme from server', error); } } // Load version info UpdatesAPI.getVersion() .then((version) => { setVersionInfo(version); // Check for updates after loading version info (non-blocking) updateStore.checkForUpdates(); }) .catch((error) => logger.error('Failed to load version', error)); setIsLoading(false); return; } // Check for OIDC session if (securityData.oidcEnabled && securityData.oidcUsername) { logger.info('[App] OIDC session detected', { user: securityData.oidcUsername }); setHasAuth(true); // OIDC is enabled, so auth is configured setProxyAuthInfo({ username: securityData.oidcUsername, logoutURL: securityData.oidcLogoutURL, // OIDC logout URL from IdP }); setNeedsAuth(false); // Initialize WebSocket for OIDC users setWsStore(acquireWsStore()); // Load theme preference from server for cross-device sync // Only use server preference if no local preference exists if (!hasLocalPreference) { try { const systemSettings = await SettingsAPI.getSystemSettings(); if (systemSettings.theme && systemSettings.theme !== '') { const prefersDark = systemSettings.theme === 'dark'; setDarkMode(prefersDark); localStorage.setItem(STORAGE_KEYS.DARK_MODE, String(prefersDark)); if (prefersDark) { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } } setHasLoadedServerTheme(true); } catch (error) { logger.error('Failed to load theme from server', error); } } // Load version info UpdatesAPI.getVersion() .then((version) => { setVersionInfo(version); // Check for updates after loading version info (non-blocking) updateStore.checkForUpdates(); }) .catch((error) => logger.error('Failed to load version', error)); setIsLoading(false); return; } // If no auth is configured, show FirstRunSetup if (!authConfigured) { logger.info('[App] No auth configured, showing Login/FirstRunSetup'); setNeedsAuth(true); // This will show the Login component which shows FirstRunSetup setIsLoading(false); return; } // If auth is configured, check if we're authenticated const stateRes = await apiFetch('/api/state', { headers: { 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json', }, }); if (stateRes.status === 401) { setNeedsAuth(true); } else { setNeedsAuth(false); // Only initialize WebSocket after successful auth check setWsStore(acquireWsStore()); // Load theme preference from server for cross-device sync // Only use server preference if no local preference exists if (!hasLocalPreference) { try { const systemSettings = await SettingsAPI.getSystemSettings(); if (systemSettings.theme && systemSettings.theme !== '') { const prefersDark = systemSettings.theme === 'dark'; setDarkMode(prefersDark); localStorage.setItem(STORAGE_KEYS.DARK_MODE, String(prefersDark)); if (prefersDark) { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } } setHasLoadedServerTheme(true); } catch (error) { logger.error('Failed to load theme from server', error); } } else { // We have a local preference, just mark that we've checked the server setHasLoadedServerTheme(true); } } } catch (error) { logger.error('Auth check error', error); try { const { clearAuth } = await import('./utils/apiClient'); clearAuth(); } catch (clearError) { logger.warn('[App] Failed to clear stored auth after auth check error', clearError); } setHasAuth(false); setNeedsAuth(true); } finally { setIsLoading(false); } }); const handleLogin = () => { window.location.reload(); }; const handleLogout = async () => { // Check if we're using proxy auth with a logout URL const proxyAuth = proxyAuthInfo(); if (proxyAuth?.logoutURL) { // Redirect to proxy auth logout URL window.location.href = proxyAuth.logoutURL; return; } try { // Import the apiClient to get CSRF token support const { apiFetch, clearAuth } = await import('./utils/apiClient'); // Clear any session data - this will include CSRF token const response = await apiFetch('/api/logout', { method: 'POST', }); if (!response.ok) { logger.error('Logout failed', { status: response.status }); } // Clear auth from apiClient clearAuth(); } catch (error) { logger.error('Logout error', error); } // Clear all local storage EXCEPT theme preference and logout flag const currentTheme = localStorage.getItem(STORAGE_KEYS.DARK_MODE); localStorage.clear(); sessionStorage.clear(); localStorage.setItem('just_logged_out', 'true'); // Preserve theme preference across logout if (currentTheme) { localStorage.setItem(STORAGE_KEYS.DARK_MODE, currentTheme); } // Clear WebSocket connection if (wsStore()) { setWsStore(null); } // Force reload to login page window.location.href = '/'; }; // Pass through the store directly (only when initialized) const enhancedStore = () => wsStore(); const DashboardView = () => ( ); const SettingsRoute = () => ( ); // Root layout component for Router const RootLayout = (props: { children?: JSX.Element }) => { return (
Loading...
} > }> Initializing...}>
{props.children}
); }; // Use Router with routes return ( } /> } /> } /> } /> } /> ); } function ConnectionStatusBadge(props: { connected: () => boolean; reconnecting: () => boolean; class?: string; }) { return (
{props.connected() ? 'Connected' : props.reconnecting() ? 'Reconnecting...' : 'Disconnected'}
); } function AppLayout(props: { connected: () => boolean; reconnecting: () => boolean; dataUpdated: () => boolean; lastUpdateText: () => string; versionInfo: () => VersionInfo | null; hasAuth: () => boolean; needsAuth: () => boolean; proxyAuthInfo: () => { username?: string; logoutURL?: string } | null; handleLogout: () => void; state: () => State; children?: JSX.Element; }) { const navigate = useNavigate(); const location = useLocation(); const readSeenPlatforms = (): Record => { if (typeof window === 'undefined') return {}; try { const stored = window.localStorage.getItem(STORAGE_KEYS.PLATFORMS_SEEN); if (stored) { const parsed = JSON.parse(stored) as Record; if (parsed && typeof parsed === 'object') { return parsed; } } } catch (error) { logger.warn('Failed to parse stored platform visibility preferences', error); } return {}; }; const [seenPlatforms, setSeenPlatforms] = createSignal>(readSeenPlatforms()); const persistSeenPlatforms = (map: Record) => { if (typeof window === 'undefined') return; try { window.localStorage.setItem(STORAGE_KEYS.PLATFORMS_SEEN, JSON.stringify(map)); } catch (error) { logger.warn('Failed to persist platform visibility preferences', error); } }; const markPlatformSeen = (platformId: string) => { setSeenPlatforms((current) => { if (current[platformId]) { return current; } const updated = { ...current, [platformId]: true }; persistSeenPlatforms(updated); return updated; }); }; // Determine active tab from current path const getActiveTab = () => { const path = location.pathname; if (path.startsWith('/proxmox')) return 'proxmox'; if (path.startsWith('/docker')) return 'docker'; if (path.startsWith('/hosts')) return 'hosts'; if (path.startsWith('/servers')) return 'hosts'; // Legacy redirect if (path.startsWith('/alerts')) return 'alerts'; if (path.startsWith('/settings')) return 'settings'; return 'proxmox'; }; const hasDockerHosts = createMemo(() => (props.state().dockerHosts?.length ?? 0) > 0); const hasHosts = createMemo(() => (props.state().hosts?.length ?? 0) > 0); const hasProxmoxHosts = createMemo( () => (props.state().nodes?.length ?? 0) > 0 || (props.state().vms?.length ?? 0) > 0 || (props.state().containers?.length ?? 0) > 0, ); createEffect(() => { if (hasDockerHosts()) { markPlatformSeen('docker'); } }); createEffect(() => { if (hasProxmoxHosts()) { markPlatformSeen('proxmox'); } }); createEffect(() => { if (hasHosts()) { markPlatformSeen('hosts'); } }); const platformTabs = createMemo(() => { return [ { id: 'proxmox' as const, label: 'Proxmox', route: '/proxmox/overview', settingsRoute: '/settings', tooltip: 'Monitor Proxmox clusters and nodes', enabled: hasProxmoxHosts() || !!seenPlatforms()['proxmox'], live: hasProxmoxHosts(), icon: ( ), }, { id: 'docker' as const, label: 'Docker', route: '/docker', settingsRoute: '/settings/docker', tooltip: 'Monitor Docker hosts and containers', enabled: hasDockerHosts() || !!seenPlatforms()['docker'], live: hasDockerHosts(), icon: ( ), }, { id: 'hosts' as const, label: 'Hosts', route: '/hosts', settingsRoute: '/settings/host-agents', tooltip: 'Monitor hosts with the host agent', enabled: hasHosts() || !!seenPlatforms()['hosts'], live: hasHosts(), icon: ( ), }, ]; }); const utilityTabs = createMemo(() => { const allAlerts = props.state().activeAlerts || []; const breakdown = allAlerts.reduce( (acc, alert: any) => { if (alert?.acknowledged) return acc; const level = String(alert?.level || '').toLowerCase(); if (level === 'critical') { acc.critical += 1; } else { acc.warning += 1; } return acc; }, { warning: 0, critical: 0 }, ); const activeAlertCount = breakdown.warning + breakdown.critical; return [ { id: 'alerts' as const, label: 'Alerts', route: '/alerts', tooltip: 'Review active alerts and automation rules', badge: null as 'update' | null, count: activeAlertCount, breakdown, icon: , }, { id: 'settings' as const, label: 'Settings', route: '/settings', tooltip: 'Configure Pulse preferences and integrations', badge: updateStore.isUpdateVisible() ? ('update' as const) : null, count: undefined, breakdown: undefined, icon: , }, ]; }); const handlePlatformClick = (platform: ReturnType[number]) => { if (platform.enabled) { navigate(platform.route); } else { navigate(platform.settingsRoute); } }; const handleUtilityClick = (tab: ReturnType[number]) => { navigate(tab.route); }; return (
{/* Header */}
Pulse RC
{props.proxyAuthInfo()?.username}
{/* Tabs */}
{(platform) => { const isActive = () => getActiveTab() === platform.id; const disabled = () => !platform.enabled; const baseClasses = 'tab relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium flex items-center gap-1 sm:gap-1.5 rounded-t border border-transparent transition-colors whitespace-nowrap cursor-pointer'; const className = () => { if (isActive()) { return `${baseClasses} bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-400 border-gray-300 dark:border-gray-700 border-b border-b-white dark:border-b-gray-800 shadow-sm font-semibold`; } if (disabled()) { return `${baseClasses} cursor-not-allowed text-gray-400 dark:text-gray-600 opacity-70 bg-gray-100/40 dark:bg-gray-800/40`; } return `${baseClasses} text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-200/60 dark:hover:bg-gray-700/60`; }; const title = () => disabled() ? `${platform.label} is not configured yet. Click to open settings.` : platform.tooltip; return ( ); }}
{(tab) => { const isActive = () => getActiveTab() === tab.id; const baseClasses = 'tab relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium flex items-center gap-1 sm:gap-1.5 rounded-t border border-transparent transition-colors whitespace-nowrap cursor-pointer'; const className = () => { if (isActive()) { return `${baseClasses} bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-400 border-gray-300 dark:border-gray-700 border-b border-b-white dark:border-b-gray-800 shadow-sm font-semibold`; } return `${baseClasses} text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-200/60 dark:hover:bg-gray-700/60`; }; return ( ); }}
{/* Main Content */}
Loading view...
}> {props.children}
{/* Footer */} ); } export default App; // Test hot-reload comment $(date)